agy-powerline 1.2.0 → 1.3.0

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.
@@ -1,6 +1,17 @@
1
1
  "use strict";
2
2
 
3
- const { rgb, fg, RESET, DIM, BOLD, NORMAL, BLINK } = require("../colors");
3
+ const {
4
+ rgb,
5
+ fg,
6
+ RESET,
7
+ DIM,
8
+ BOLD,
9
+ NORMAL,
10
+ BLINK,
11
+ gradientColors,
12
+ withContrast,
13
+ } = require("../colors");
14
+ const { gradientStops, terminalBackground } = require("../themes");
4
15
  const {
5
16
  basenameOf,
6
17
  homePath,
@@ -8,6 +19,9 @@ const {
8
19
  paceIndex,
9
20
  paceGlyph,
10
21
  formatGitSync,
22
+ formatGitOperation,
23
+ gitChangeParts,
24
+ gitFileParts,
11
25
  formatTasks,
12
26
  formatMode,
13
27
  clipText,
@@ -15,12 +29,13 @@ const {
15
29
  formatContextTokens,
16
30
  shortModelName,
17
31
  formatTokenSplit,
32
+ formatUsage,
18
33
  resolveQuotaThresholds,
19
34
  resolveContextThresholds,
20
35
  shouldBlink,
21
36
  } = require("../formatters");
22
37
  const { joinFlex } = require("./pills");
23
- const { execWidgetOutput } = require("../exec");
38
+ const { execWidgetOutput, restoreAfterReset } = require("../exec");
24
39
  const {
25
40
  NAMED_COLORS,
26
41
  resolveColor,
@@ -28,6 +43,8 @@ const {
28
43
  widgetTag,
29
44
  isRaw,
30
45
  isPaced,
46
+ METER_STYLES,
47
+ meterStyle,
31
48
  hasNoData,
32
49
  RAM_CAUTION,
33
50
  RAM_DANGER,
@@ -126,13 +143,14 @@ function renderDotMeter(
126
143
  : colorForUsedPct(pct, palette, options));
127
144
 
128
145
  const marker = paceIndex(options.elapsedPct, width);
146
+ const [full, blank] = METER_STYLES[options.style] || METER_STYLES.dots;
129
147
 
130
148
  if (marker < 0) {
131
149
  if (ascii) {
132
150
  return fg(color) + "#".repeat(filled) + DIM + "-".repeat(empty) + RESET;
133
151
  }
134
152
  const trackColor = palette.track || rgb(60, 65, 75);
135
- return fg(color) + "●".repeat(filled) + fg(trackColor) + "●".repeat(empty) + RESET;
153
+ return fg(color) + full.repeat(filled) + fg(trackColor) + blank.repeat(empty) + RESET;
136
154
  }
137
155
 
138
156
  // The marker replaces the dot where the quota window currently is.
@@ -145,9 +163,9 @@ function renderDotMeter(
145
163
  if (i === marker) {
146
164
  out += undim + fg(markerColor) + paceGlyph(ascii);
147
165
  } else if (i < filled) {
148
- out += undim + fg(color) + (ascii ? "#" : "●");
166
+ out += undim + fg(color) + (ascii ? "#" : full);
149
167
  } else {
150
- out += ascii ? DIM + "-" : fg(trackColor) + "●";
168
+ out += ascii ? DIM + "-" : fg(trackColor) + blank;
151
169
  }
152
170
  }
153
171
  return out + RESET;
@@ -159,8 +177,10 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
159
177
  const tag = (icon) => widgetTag(id, { emojis, raw, icon });
160
178
  // Meters: 25 dots, or 12 when the bar is fitted to a narrow terminal.
161
179
  const dots = widget?.compact ? 12 : 25;
180
+ const style = meterStyle(widget, config, "lines");
162
181
  const pace = (quota, thresh) => ({
163
182
  ...thresh,
183
+ style,
164
184
  elapsedPct: isPaced(widget, config) ? quota.elapsedPct : null,
165
185
  });
166
186
  const over200k = session.exceeds200k ? " >200k" : "";
@@ -304,7 +324,11 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
304
324
  blinkSeq +
305
325
  fg(palette.text) +
306
326
  tag() +
307
- renderDotMeter(session.contextPct, dots, ascii, palette, false, { ...thresh, color }) +
327
+ renderDotMeter(session.contextPct, dots, ascii, palette, false, {
328
+ ...thresh,
329
+ color,
330
+ style,
331
+ }) +
308
332
  " " +
309
333
  fg(color) +
310
334
  `${formatContextTokens(session.usedTokens, session.contextSize)} ` +
@@ -357,6 +381,7 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
357
381
  if (!session.git || !session.git.branch) {
358
382
  return null;
359
383
  }
384
+ const opStr = widget.operation === false ? "" : formatGitOperation(session.git.operation);
360
385
  const syncStr = formatGitSync(session.git.ahead, session.git.behind, ascii);
361
386
  const syncPart = syncStr ? ` ${syncStr}` : "";
362
387
  return (
@@ -365,6 +390,7 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
365
390
  "(" +
366
391
  session.git.branch +
367
392
  (session.git.dirty ? `${fg(palette.gitDirty)}*` : "") +
393
+ (opStr ? `${fg(palette.gitDirty)} ${opStr}` : "") +
368
394
  (syncPart ? `${fg(palette.git)}${syncPart}` : "") +
369
395
  fg(palette.git) +
370
396
  ")" +
@@ -372,6 +398,52 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
372
398
  );
373
399
  }
374
400
 
401
+ case "gitChanges": {
402
+ const parts = gitChangeParts(session.gitDetails?.changes, ascii);
403
+ if (!parts.length) {
404
+ return null;
405
+ }
406
+ const shade = { added: palette.quotaLow, removed: palette.quotaCrit };
407
+ return (
408
+ fg(palette.git) +
409
+ tag() +
410
+ parts.map((part) => fg(shade[part.kind]) + part.text).join(" ") +
411
+ RESET
412
+ );
413
+ }
414
+
415
+ case "gitFiles": {
416
+ const parts = gitFileParts(session.gitDetails?.files, ascii);
417
+ if (!parts.length) {
418
+ return null;
419
+ }
420
+ const shade = (kind) => (kind === "conflicts" ? palette.quotaCrit : palette.git);
421
+ return (
422
+ fg(palette.git) +
423
+ tag() +
424
+ parts.map((part) => fg(shade(part.kind)) + part.text).join(" ") +
425
+ RESET
426
+ );
427
+ }
428
+
429
+ case "gitSha":
430
+ if (!session.gitDetails?.sha) {
431
+ return null;
432
+ }
433
+ return fg(palette.git) + tag() + session.gitDetails.sha + RESET;
434
+
435
+ case "gitStash":
436
+ if (!(session.gitDetails?.stash > 0)) {
437
+ return null;
438
+ }
439
+ return fg(palette.git) + tag() + session.gitDetails.stash + RESET;
440
+
441
+ case "gitWorktree":
442
+ if (!session.gitDetails?.worktree) {
443
+ return null;
444
+ }
445
+ return fg(palette.git) + tag() + session.gitDetails.worktree + RESET;
446
+
375
447
  case "plan":
376
448
  if (!session.plan) {
377
449
  return null;
@@ -474,6 +546,32 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
474
546
  RESET
475
547
  );
476
548
 
549
+ case "tokensToday":
550
+ case "tokensWeek": {
551
+ const usage = formatUsage(
552
+ session.usage?.[id === "tokensToday" ? "today" : "week"],
553
+ widget,
554
+ ascii
555
+ );
556
+ if (!usage) {
557
+ return null;
558
+ }
559
+ // A budget alerts like the context widgets: caution, then danger.
560
+ const thresh = resolveContextThresholds(widget, config);
561
+ const level =
562
+ usage.pct === null
563
+ ? "normal"
564
+ : usage.pct >= thresh.crit
565
+ ? "crit"
566
+ : usage.pct >= thresh.warn
567
+ ? "warn"
568
+ : "normal";
569
+ const color =
570
+ level === "crit" ? palette.quotaCrit : level === "warn" ? palette.quotaHigh : palette.text;
571
+ const blinkSeq = !ascii && shouldBlink(level, thresh.blink) ? BLINK : "";
572
+ return blinkSeq + fg(palette.text) + tag() + fg(color) + usage.text + RESET;
573
+ }
574
+
477
575
  case "customText": {
478
576
  if (!widget?.text) {
479
577
  return null;
@@ -491,7 +589,7 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
491
589
  // buildLinesRows already swapped the widget's color into the palette.
492
590
  const col = palette.location;
493
591
  const iconPart = emojis && icon && !raw ? `${icon} ` : "";
494
- return fg(col) + iconPart + named + output + RESET;
592
+ return fg(col) + iconPart + named + restoreAfterReset(output, fg(col)) + RESET;
495
593
  }
496
594
 
497
595
  default:
@@ -530,10 +628,22 @@ function buildLinesRows(session, config, palette, emojis, ascii = false, colors
530
628
  rowDefs.push(currentRowDef);
531
629
 
532
630
  const rows = [];
631
+ const gradient = colors ? gradientStops(config?.gradient) : null;
632
+ const background = terminalBackground(colors);
633
+
634
+ function renderItem(widget, widgetPal) {
635
+ const item = buildLinesSegment(widget.id, session, widgetPal, emojis, ascii, widget, config);
636
+ // Bold has to survive the resets inside the segment.
637
+ return item && widget.bold === true
638
+ ? BOLD + item.split(RESET).join(RESET + BOLD) + RESET
639
+ : item;
640
+ }
533
641
 
534
642
  for (const def of rowDefs) {
535
643
  const groups = [];
536
644
  let currentGroup = [];
645
+ // The widgets shown in this row, so a gradient spreads over exactly those.
646
+ const placed = [];
537
647
 
538
648
  function flushGroup() {
539
649
  if (currentGroup.length) {
@@ -552,26 +662,35 @@ function buildLinesRows(session, config, palette, emojis, ascii = false, colors
552
662
  continue;
553
663
  }
554
664
 
555
- const item = buildLinesSegment(
556
- widget.id,
557
- session,
558
- widgetPalette(widget, palette, colors),
559
- emojis,
560
- ascii,
561
- widget,
562
- config
563
- );
665
+ const item = renderItem(widget, widgetPalette(widget, palette, colors));
564
666
 
565
667
  if (item) {
566
- // Bold has to survive the resets inside the segment.
567
- currentGroup.push(
568
- widget.bold === true ? BOLD + item.split(RESET).join(RESET + BOLD) + RESET : item
569
- );
668
+ placed.push({ widget, group: currentGroup, index: currentGroup.length });
669
+ currentGroup.push(item);
570
670
  }
571
671
  }
572
672
 
573
673
  flushGroup();
574
674
 
675
+ // Each widget's text takes its step of the gradient, made readable on the
676
+ // terminal background; alert colors and a widget's own color stay.
677
+ if (gradient && placed.length) {
678
+ const shades = gradientColors(gradient, placed.length);
679
+ placed.forEach(({ widget, group, index }, i) => {
680
+ if (resolveColor(widget.color, colors)) {
681
+ return;
682
+ }
683
+ const shade = withContrast(shades[i], background);
684
+ const item = renderItem(
685
+ widget,
686
+ widgetPalette({ ...widget, color: shade }, palette, colors)
687
+ );
688
+ if (item) {
689
+ group[index] = item;
690
+ }
691
+ });
692
+ }
693
+
575
694
  if (groups.length) {
576
695
  rows.push(groups);
577
696
  }
@@ -7,11 +7,15 @@ const {
7
7
  bgDefault,
8
8
  fg,
9
9
  BOLD,
10
+ DIM,
10
11
  NORMAL,
11
12
  BLINK,
12
13
  BLINK_OFF,
13
14
  getActiveDepth,
15
+ gradientColors,
16
+ readableText,
14
17
  } = require("../colors");
18
+ const { gradientStops } = require("../themes");
15
19
  const { pickGlyph, glyphChar } = require("../powerline");
16
20
  const {
17
21
  quotaColor,
@@ -23,7 +27,11 @@ const {
23
27
  formatContextTokens,
24
28
  shortModelName,
25
29
  formatTokenSplit,
30
+ formatUsage,
26
31
  formatGitSync,
32
+ formatGitOperation,
33
+ gitChangeParts,
34
+ gitFileParts,
27
35
  formatTasks,
28
36
  formatMode,
29
37
  clipText,
@@ -35,12 +43,15 @@ const {
35
43
  paceIndex,
36
44
  paceGlyph,
37
45
  } = require("../formatters");
38
- const { execWidgetOutput } = require("../exec");
46
+ const { execWidgetOutput, restoreAfterReset } = require("../exec");
39
47
  const {
48
+ resolveColor,
40
49
  widgetColors,
41
50
  widgetTag,
42
51
  isRaw,
43
52
  isPaced,
53
+ METER_STYLES,
54
+ meterStyle,
44
55
  hasNoData,
45
56
  RAM_CAUTION,
46
57
  RAM_DANGER,
@@ -56,6 +67,9 @@ function fillMeter(pct, width, fillColor, emptyColor, segmentColor, pace = {}) {
56
67
  const ratio = Math.max(0, Math.min(100, Number(pct))) / 100;
57
68
  const used = Math.round(ratio * width);
58
69
  const marker = paceIndex(pace.elapsedPct, width);
70
+ if (METER_STYLES[pace.style]) {
71
+ return glyphMeter(used, marker, width, METER_STYLES[pace.style], pace);
72
+ }
59
73
  // The fill is a background color; without color it is drawn with glyphs.
60
74
  const plain = getActiveDepth() === "none";
61
75
  const glyph = (filled) => (!plain ? " " : pace.ascii ? (filled ? "#" : "-") : filled ? "█" : "░");
@@ -73,6 +87,31 @@ function fillMeter(pct, width, fillColor, emptyColor, segmentColor, pace = {}) {
73
87
  return meter + bg(segmentColor);
74
88
  }
75
89
 
90
+ // A glyph style draws on the pill's own color, with no cells: filled glyphs in
91
+ // the pill's text color (readable on the pill, gradients included), empty
92
+ // ones in the same color dimmed. ASCII mode keeps # and -.
93
+ function glyphMeter(used, marker, width, [full, empty], { ascii, textColor }) {
94
+ let meter = fg(textColor);
95
+ let dim = false;
96
+
97
+ for (let i = 0; i < width; i++) {
98
+ const blank = i !== marker && i >= used;
99
+ if (blank !== dim) {
100
+ meter += blank ? DIM : NORMAL;
101
+ dim = blank;
102
+ }
103
+ if (i === marker) {
104
+ meter += paceGlyph(ascii);
105
+ } else if (ascii) {
106
+ meter += blank ? "-" : "#";
107
+ } else {
108
+ meter += blank ? empty : full;
109
+ }
110
+ }
111
+
112
+ return dim ? meter + NORMAL : meter;
113
+ }
114
+
76
115
  function meterSegment(
77
116
  color,
78
117
  pct,
@@ -437,14 +476,52 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
437
476
  return null;
438
477
  }
439
478
  const mark = session.git.dirty ? `${ascii ? "* " : "● "}` : "";
479
+ const opStr = widget.operation === false ? "" : formatGitOperation(session.git.operation);
480
+ const opPart = opStr ? `|${opStr}` : "";
440
481
  const syncStr = formatGitSync(session.git.ahead, session.git.behind, ascii);
441
482
  const syncPart = syncStr ? ` ${syncStr}` : "";
442
483
  return pill(
443
484
  session.git.dirty ? colors.gitDirty : colors.git,
444
- lbl(`${mark}${session.git.branch}${syncPart}`)
485
+ lbl(`${mark}${session.git.branch}${opPart}${syncPart}`)
445
486
  );
446
487
  }
447
488
 
489
+ case "gitChanges": {
490
+ const parts = gitChangeParts(session.gitDetails?.changes, ascii);
491
+ if (!parts.length) {
492
+ return null;
493
+ }
494
+ return pill(colors.git, lbl(parts.map((part) => part.text).join(" ")));
495
+ }
496
+
497
+ case "gitFiles": {
498
+ const files = session.gitDetails?.files;
499
+ const parts = gitFileParts(files, ascii);
500
+ if (!parts.length) {
501
+ return null;
502
+ }
503
+ const color = files.conflicts > 0 ? colors.gitDirty : colors.git;
504
+ return pill(color, lbl(parts.map((part) => part.text).join(" ")));
505
+ }
506
+
507
+ case "gitSha":
508
+ if (!session.gitDetails?.sha) {
509
+ return null;
510
+ }
511
+ return pill(colors.git, lbl(session.gitDetails.sha));
512
+
513
+ case "gitStash":
514
+ if (!(session.gitDetails?.stash > 0)) {
515
+ return null;
516
+ }
517
+ return pill(colors.git, lbl(String(session.gitDetails.stash)));
518
+
519
+ case "gitWorktree":
520
+ if (!session.gitDetails?.worktree) {
521
+ return null;
522
+ }
523
+ return pill(colors.git, lbl(session.gitDetails.worktree));
524
+
448
525
  case "plan":
449
526
  if (!session.plan) {
450
527
  return null;
@@ -547,6 +624,32 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
547
624
  )
548
625
  );
549
626
 
627
+ case "tokensToday":
628
+ case "tokensWeek": {
629
+ const usage = formatUsage(
630
+ session.usage?.[id === "tokensToday" ? "today" : "week"],
631
+ widget,
632
+ ascii
633
+ );
634
+ if (!usage) {
635
+ return null;
636
+ }
637
+ if (usage.pct === null) {
638
+ return pill(colors.meta, lbl(usage.text));
639
+ }
640
+ // A budget alerts like the context widgets: caution, then danger.
641
+ const thresh = resolveContextThresholds(widget, config);
642
+ const level =
643
+ usage.pct >= thresh.crit ? "crit" : usage.pct >= thresh.warn ? "warn" : "normal";
644
+ const color = contextColor(usage.pct, colors.meta, false, {
645
+ crit: thresh.crit,
646
+ warn: thresh.warn,
647
+ critColor: colors.warn,
648
+ warnColor: colors.caution,
649
+ });
650
+ return pill(color, lbl(usage.text), !ascii && shouldBlink(level, thresh.blink));
651
+ }
652
+
550
653
  case "customText": {
551
654
  if (!widget?.text) {
552
655
  return null;
@@ -573,11 +676,58 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
573
676
  }
574
677
  }
575
678
 
679
+ // Pill text on a gradient color that the theme's text doesn't read on.
680
+ const READABLE_TEXT = [[22, 22, 28], WHITE, [0, 0, 0]];
681
+
576
682
  function buildRows(session, config, colors) {
577
683
  const rows = [];
578
684
  let groups = [];
579
685
  let current = [];
686
+ // The widgets shown in the current row, so a gradient can be spread over
687
+ // exactly those once the row is complete.
688
+ let placed = [];
580
689
  const splitEveryWidget = !config.powerline.enabled;
690
+ const gradient = gradientStops(config.gradient);
691
+
692
+ function styledSegment(widget, ownColors) {
693
+ const segment = buildSegment(
694
+ widget.id,
695
+ session,
696
+ ownColors,
697
+ config.emojis,
698
+ config.ascii === true,
699
+ widget,
700
+ config
701
+ );
702
+ if (segment) {
703
+ segment.textColor = ownColors.text;
704
+ segment.bold = widget.bold !== false;
705
+ if (segment.meter) {
706
+ segment.style = meterStyle(widget, config, "pills");
707
+ }
708
+ }
709
+ return segment;
710
+ }
711
+
712
+ // Each widget takes its step of the gradient in place of its identity
713
+ // color; alert colors and a widget's own color stay as they are.
714
+ function applyGradient() {
715
+ const shades = gradientColors(gradient, placed.length);
716
+ placed.forEach(({ widget, group, index }, i) => {
717
+ if (resolveColor(widget.color, colors)) {
718
+ return;
719
+ }
720
+ const ownColors = widgetColors({ ...widget, color: shades[i] }, colors);
721
+ const segment = styledSegment(widget, ownColors);
722
+ if (!segment) {
723
+ return;
724
+ }
725
+ if (!resolveColor(widget.textColor, colors)) {
726
+ segment.textColor = readableText(segment.color, ownColors.text, READABLE_TEXT);
727
+ }
728
+ group[index] = segment;
729
+ });
730
+ }
581
731
 
582
732
  function flushGroup() {
583
733
  if (current.length) {
@@ -589,6 +739,11 @@ function buildRows(session, config, colors) {
589
739
  function flushRow() {
590
740
  flushGroup();
591
741
 
742
+ if (gradient && placed.length) {
743
+ applyGradient();
744
+ }
745
+ placed = [];
746
+
592
747
  if (groups.length) {
593
748
  rows.push(groups);
594
749
  groups = [];
@@ -606,28 +761,18 @@ function buildRows(session, config, colors) {
606
761
  continue;
607
762
  }
608
763
 
609
- const ownColors = widgetColors(widget, colors);
610
- const segment = buildSegment(
611
- widget.id,
612
- session,
613
- ownColors,
614
- config.emojis,
615
- config.ascii === true,
616
- widget,
617
- config
618
- );
764
+ const segment = styledSegment(widget, widgetColors(widget, colors));
619
765
 
620
766
  if (!segment) {
621
767
  continue;
622
768
  }
623
769
 
624
- segment.textColor = ownColors.text;
625
- segment.bold = widget.bold !== false;
770
+ const group = splitEveryWidget ? [] : current;
771
+ placed.push({ widget, group, index: group.length });
772
+ group.push(segment);
626
773
 
627
774
  if (splitEveryWidget) {
628
- groups.push([segment]);
629
- } else {
630
- current.push(segment);
775
+ groups.push(group);
631
776
  }
632
777
  }
633
778
 
@@ -674,7 +819,9 @@ function segmentContent(text, color, textColor, blink = false, bold = true) {
674
819
  const blinkSeq = blink ? BLINK : "";
675
820
  const blinkOff = blink ? BLINK_OFF : "";
676
821
  // NORMAL clears bold left over from a previous widget.
677
- return bg(color) + fg(textColor) + blinkSeq + (bold ? BOLD : NORMAL) + text + blinkOff;
822
+ const style = bg(color) + fg(textColor) + blinkSeq + (bold ? BOLD : NORMAL);
823
+ // Only exec output with "colors": true still holds escapes here.
824
+ return style + (text.includes("\x1b") ? restoreAfterReset(text, style) : text) + blinkOff;
678
825
  }
679
826
 
680
827
  function renderMeterSegment(segment, colors, ascii = false) {
@@ -685,6 +832,8 @@ function renderMeterSegment(segment, colors, ascii = false) {
685
832
  const meter = fillMeter(displayPct, 12, segment.fill, segment.empty, segment.color, {
686
833
  elapsedPct: segment.elapsedPct,
687
834
  ascii,
835
+ style: segment.style,
836
+ textColor,
688
837
  });
689
838
  const bold = segment.bold !== false;
690
839