agy-powerline 1.2.0 → 1.4.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.
@@ -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,14 @@ 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
+ // pips, the default, needs a Unicode font: ASCII mode keeps the cells.
71
+ const pips = pace.style === "pips";
72
+ if (pips && !pace.ascii && getActiveDepth() !== "none") {
73
+ return pipMeter(used, marker, width, fillColor, emptyColor) + bg(segmentColor);
74
+ }
75
+ if (METER_STYLES[pace.style] && !(pips && pace.ascii)) {
76
+ return glyphMeter(used, marker, width, METER_STYLES[pace.style], pace);
77
+ }
59
78
  // The fill is a background color; without color it is drawn with glyphs.
60
79
  const plain = getActiveDepth() === "none";
61
80
  const glyph = (filled) => (!plain ? " " : pace.ascii ? (filled ? "#" : "-") : filled ? "█" : "░");
@@ -73,6 +92,51 @@ function fillMeter(pct, width, fillColor, emptyColor, segmentColor, pace = {}) {
73
92
  return meter + bg(segmentColor);
74
93
  }
75
94
 
95
+ // Small dots on the pill's own color: the filled ones in the light fill color,
96
+ // the rest in the dark track color, the same pair cells use. The pace marker
97
+ // is a larger dot in the other color, so nothing in the meter is a line.
98
+ function pipMeter(used, marker, width, fillColor, emptyColor) {
99
+ let meter = "";
100
+ let color = null;
101
+
102
+ for (let i = 0; i < width; i++) {
103
+ const filled = i < used;
104
+ const next = i === marker ? (filled ? emptyColor : fillColor) : filled ? fillColor : emptyColor;
105
+ if (next !== color) {
106
+ meter += fg(next);
107
+ color = next;
108
+ }
109
+ meter += i === marker ? METER_STYLES.pips[2] : METER_STYLES.pips[0];
110
+ }
111
+
112
+ return meter;
113
+ }
114
+
115
+ // A glyph style draws on the pill's own color, with no cells: filled glyphs in
116
+ // the pill's text color (readable on the pill, gradients included), empty
117
+ // ones in the same color dimmed. ASCII mode keeps # and -.
118
+ function glyphMeter(used, marker, width, [full, empty, pace], { ascii, textColor }) {
119
+ let meter = fg(textColor);
120
+ let dim = false;
121
+
122
+ for (let i = 0; i < width; i++) {
123
+ const blank = i !== marker && i >= used;
124
+ if (blank !== dim) {
125
+ meter += blank ? DIM : NORMAL;
126
+ dim = blank;
127
+ }
128
+ if (i === marker) {
129
+ meter += (!ascii && pace) || paceGlyph(ascii);
130
+ } else if (ascii) {
131
+ meter += blank ? "-" : "#";
132
+ } else {
133
+ meter += blank ? empty : full;
134
+ }
135
+ }
136
+
137
+ return dim ? meter + NORMAL : meter;
138
+ }
139
+
76
140
  function meterSegment(
77
141
  color,
78
142
  pct,
@@ -437,14 +501,52 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
437
501
  return null;
438
502
  }
439
503
  const mark = session.git.dirty ? `${ascii ? "* " : "● "}` : "";
504
+ const opStr = widget.operation === false ? "" : formatGitOperation(session.git.operation);
505
+ const opPart = opStr ? `|${opStr}` : "";
440
506
  const syncStr = formatGitSync(session.git.ahead, session.git.behind, ascii);
441
507
  const syncPart = syncStr ? ` ${syncStr}` : "";
442
508
  return pill(
443
509
  session.git.dirty ? colors.gitDirty : colors.git,
444
- lbl(`${mark}${session.git.branch}${syncPart}`)
510
+ lbl(`${mark}${session.git.branch}${opPart}${syncPart}`)
445
511
  );
446
512
  }
447
513
 
514
+ case "gitChanges": {
515
+ const parts = gitChangeParts(session.gitDetails?.changes, ascii);
516
+ if (!parts.length) {
517
+ return null;
518
+ }
519
+ return pill(colors.git, lbl(parts.map((part) => part.text).join(" ")));
520
+ }
521
+
522
+ case "gitFiles": {
523
+ const files = session.gitDetails?.files;
524
+ const parts = gitFileParts(files, ascii);
525
+ if (!parts.length) {
526
+ return null;
527
+ }
528
+ const color = files.conflicts > 0 ? colors.gitDirty : colors.git;
529
+ return pill(color, lbl(parts.map((part) => part.text).join(" ")));
530
+ }
531
+
532
+ case "gitSha":
533
+ if (!session.gitDetails?.sha) {
534
+ return null;
535
+ }
536
+ return pill(colors.git, lbl(session.gitDetails.sha));
537
+
538
+ case "gitStash":
539
+ if (!(session.gitDetails?.stash > 0)) {
540
+ return null;
541
+ }
542
+ return pill(colors.git, lbl(String(session.gitDetails.stash)));
543
+
544
+ case "gitWorktree":
545
+ if (!session.gitDetails?.worktree) {
546
+ return null;
547
+ }
548
+ return pill(colors.git, lbl(session.gitDetails.worktree));
549
+
448
550
  case "plan":
449
551
  if (!session.plan) {
450
552
  return null;
@@ -547,6 +649,32 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
547
649
  )
548
650
  );
549
651
 
652
+ case "tokensToday":
653
+ case "tokensWeek": {
654
+ const usage = formatUsage(
655
+ session.usage?.[id === "tokensToday" ? "today" : "week"],
656
+ widget,
657
+ ascii
658
+ );
659
+ if (!usage) {
660
+ return null;
661
+ }
662
+ if (usage.pct === null) {
663
+ return pill(colors.meta, lbl(usage.text));
664
+ }
665
+ // A budget alerts like the context widgets: caution, then danger.
666
+ const thresh = resolveContextThresholds(widget, config);
667
+ const level =
668
+ usage.pct >= thresh.crit ? "crit" : usage.pct >= thresh.warn ? "warn" : "normal";
669
+ const color = contextColor(usage.pct, colors.meta, false, {
670
+ crit: thresh.crit,
671
+ warn: thresh.warn,
672
+ critColor: colors.warn,
673
+ warnColor: colors.caution,
674
+ });
675
+ return pill(color, lbl(usage.text), !ascii && shouldBlink(level, thresh.blink));
676
+ }
677
+
550
678
  case "customText": {
551
679
  if (!widget?.text) {
552
680
  return null;
@@ -573,11 +701,57 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
573
701
  }
574
702
  }
575
703
 
704
+ // Pill text on a gradient color that the theme's text doesn't read on.
705
+ const READABLE_TEXT = [[22, 22, 28], WHITE, [0, 0, 0]];
706
+
576
707
  function buildRows(session, config, colors) {
577
708
  const rows = [];
578
709
  let groups = [];
579
710
  let current = [];
580
- const splitEveryWidget = !config.powerline.enabled;
711
+ // The widgets shown in the current row, so a gradient can be spread over
712
+ // exactly those once the row is complete.
713
+ let placed = [];
714
+ const gradient = gradientStops(config.gradient);
715
+
716
+ function styledSegment(widget, ownColors) {
717
+ const segment = buildSegment(
718
+ widget.id,
719
+ session,
720
+ ownColors,
721
+ config.emojis,
722
+ config.ascii === true,
723
+ widget,
724
+ config
725
+ );
726
+ if (segment) {
727
+ segment.textColor = ownColors.text;
728
+ segment.bold = widget.bold !== false;
729
+ if (segment.meter) {
730
+ segment.style = meterStyle(widget, config, "pills");
731
+ }
732
+ }
733
+ return segment;
734
+ }
735
+
736
+ // Each widget takes its step of the gradient in place of its identity
737
+ // color; alert colors and a widget's own color stay as they are.
738
+ function applyGradient() {
739
+ const shades = gradientColors(gradient, placed.length);
740
+ placed.forEach(({ widget, group, index }, i) => {
741
+ if (resolveColor(widget.color, colors)) {
742
+ return;
743
+ }
744
+ const ownColors = widgetColors({ ...widget, color: shades[i] }, colors);
745
+ const segment = styledSegment(widget, ownColors);
746
+ if (!segment) {
747
+ return;
748
+ }
749
+ if (!resolveColor(widget.textColor, colors)) {
750
+ segment.textColor = readableText(segment.color, ownColors.text, READABLE_TEXT);
751
+ }
752
+ group[index] = segment;
753
+ });
754
+ }
581
755
 
582
756
  function flushGroup() {
583
757
  if (current.length) {
@@ -589,6 +763,11 @@ function buildRows(session, config, colors) {
589
763
  function flushRow() {
590
764
  flushGroup();
591
765
 
766
+ if (gradient && placed.length) {
767
+ applyGradient();
768
+ }
769
+ placed = [];
770
+
592
771
  if (groups.length) {
593
772
  rows.push(groups);
594
773
  groups = [];
@@ -606,29 +785,14 @@ function buildRows(session, config, colors) {
606
785
  continue;
607
786
  }
608
787
 
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
- );
788
+ const segment = styledSegment(widget, widgetColors(widget, colors));
619
789
 
620
790
  if (!segment) {
621
791
  continue;
622
792
  }
623
793
 
624
- segment.textColor = ownColors.text;
625
- segment.bold = widget.bold !== false;
626
-
627
- if (splitEveryWidget) {
628
- groups.push([segment]);
629
- } else {
630
- current.push(segment);
631
- }
794
+ placed.push({ widget, group: current, index: current.length });
795
+ current.push(segment);
632
796
  }
633
797
 
634
798
  flushRow();
@@ -649,20 +813,36 @@ function rightCap(color, terminal, glyph) {
649
813
  return glyph ? fg(color) + capBackground(terminal) + glyph : "";
650
814
  }
651
815
 
816
+ // Compared as the terminal will draw them: at 256 colors two different colors
817
+ // can land on the same code, and without color everything is the same.
652
818
  function sameColor(a, b) {
653
- return String(a) === String(b);
819
+ return bg(a) === bg(b);
820
+ }
821
+
822
+ // Each solid Powerline glyph (U+E0B0 to U+E0BE, even code points) has a thin
823
+ // outline twin at the next code point. The round twins are drawn shorter than
824
+ // the cell in many Nerd Fonts, so round joins use a box-drawing line, which
825
+ // spans the full height.
826
+ function thinGlyph(glyph) {
827
+ const code = glyph.codePointAt(0);
828
+ if (glyph.length !== 1 || code < 0xe0b0 || code > 0xe0be || code % 2 !== 0) {
829
+ return glyph;
830
+ }
831
+ return code === 0xe0b4 || code === 0xe0b6 ? "│" : String.fromCodePoint(code + 1);
654
832
  }
655
833
 
656
834
  // A separator between two widgets of the same color would vanish, so it is
657
- // drawn in the text color instead. Inverted separators (meant for
835
+ // drawn as its thin outline in the text color instead, the way Powerline
836
+ // divides one color. ASCII joins are characters, not shapes, so they take the
837
+ // text color too, which reads on any pill. Inverted separators (meant for
658
838
  // left-pointing glyphs) take the next widget's color on the previous one's.
659
- function joinSegments(previous, next, glyph, invert, textColor) {
839
+ function joinSegments(previous, next, glyph, invert, textColor, ascii = false) {
660
840
  if (!glyph) {
661
841
  return "";
662
842
  }
663
843
 
664
- if (sameColor(previous.color, next.color)) {
665
- return fg(next.textColor || textColor) + bg(next.color) + glyph;
844
+ if (ascii || sameColor(previous.color, next.color)) {
845
+ return fg(next.textColor || textColor) + bg(next.color) + thinGlyph(glyph);
666
846
  }
667
847
 
668
848
  return invert
@@ -674,7 +854,9 @@ function segmentContent(text, color, textColor, blink = false, bold = true) {
674
854
  const blinkSeq = blink ? BLINK : "";
675
855
  const blinkOff = blink ? BLINK_OFF : "";
676
856
  // NORMAL clears bold left over from a previous widget.
677
- return bg(color) + fg(textColor) + blinkSeq + (bold ? BOLD : NORMAL) + text + blinkOff;
857
+ const style = bg(color) + fg(textColor) + blinkSeq + (bold ? BOLD : NORMAL);
858
+ // Only exec output with "colors": true still holds escapes here.
859
+ return style + (text.includes("\x1b") ? restoreAfterReset(text, style) : text) + blinkOff;
678
860
  }
679
861
 
680
862
  function renderMeterSegment(segment, colors, ascii = false) {
@@ -685,6 +867,8 @@ function renderMeterSegment(segment, colors, ascii = false) {
685
867
  const meter = fillMeter(displayPct, 12, segment.fill, segment.empty, segment.color, {
686
868
  elapsedPct: segment.elapsedPct,
687
869
  ascii,
870
+ style: segment.style,
871
+ textColor,
688
872
  });
689
873
  const bold = segment.bold !== false;
690
874
 
@@ -697,13 +881,19 @@ function renderMeterSegment(segment, colors, ascii = false) {
697
881
 
698
882
  // Caps cycle per pill and separators per join, counted across the whole
699
883
  // statusline, so lists with several glyphs alternate from left to right.
884
+ // Glyphs are drawn without bold, which some fonts draw wider or shifted, and
885
+ // the pill ends on the terminal's background so the padding after it stays
886
+ // clear even without an end cap. Without color, solid caps would be blobs of
887
+ // text color, so only ASCII brackets are kept.
700
888
  function renderGroup(group, colors, config, counters = { pill: 0, separator: 0 }) {
701
889
  const { powerline } = config;
702
890
  const ascii = config.ascii === true;
703
891
  const terminal = colors.terminal;
704
892
  const pillIndex = counters.pill++;
705
- const startCap = glyphChar(pickGlyph(powerline.startCaps, pillIndex), "startCap", ascii);
706
- const endCap = glyphChar(pickGlyph(powerline.endCaps, pillIndex), "endCap", ascii);
893
+ const caps = ascii || getActiveDepth() !== "none";
894
+ const cap = (list, slot) => (caps ? glyphChar(pickGlyph(list, pillIndex), slot, ascii) : "");
895
+ const startCap = cap(powerline.startCaps, "startCap");
896
+ const endCap = cap(powerline.endCaps, "endCap");
707
897
 
708
898
  let out = leftCap(group[0].color, terminal, startCap);
709
899
 
@@ -724,13 +914,22 @@ function renderGroup(group, colors, config, counters = { pill: 0, separator: 0 }
724
914
  const slot = counters.separator++;
725
915
  const glyph = glyphChar(pickGlyph(powerline.separators, slot), "separator", ascii);
726
916
  const invert = powerline.separatorInvert[slot % (powerline.separators.length || 1)];
727
- out += joinSegments(segment, group[i + 1], glyph, invert === true, colors.text);
917
+ out +=
918
+ NORMAL + joinSegments(segment, group[i + 1], glyph, invert === true, colors.text, ascii);
728
919
  }
729
920
  }
730
921
 
731
- out += rightCap(group[group.length - 1].color, terminal, endCap);
922
+ const last = group[group.length - 1].color;
923
+ return out + NORMAL + (endCap ? rightCap(last, terminal, endCap) : capBackground(terminal));
924
+ }
732
925
 
733
- return out;
926
+ // With joins off every widget is a pill of its own, one space from the next;
927
+ // the Flex Separator still decides where the row opens up.
928
+ function renderPills(group, colors, config, counters) {
929
+ if (config.powerline.enabled) {
930
+ return renderGroup(group, colors, config, counters);
931
+ }
932
+ return group.map((segment) => renderGroup([segment], colors, config, counters)).join(" ");
734
933
  }
735
934
 
736
935
  function joinFlex(parts, terminalWidth) {
@@ -768,7 +967,7 @@ function renderPillRows(rows, session, colors, config) {
768
967
  return rows
769
968
  .map((row) =>
770
969
  joinFlex(
771
- row.map((group) => renderGroup(group, colors, config, counters)),
970
+ row.map((group) => renderPills(group, colors, config, counters)),
772
971
  session.terminalWidth
773
972
  )
774
973
  )
package/src/lib/paths.js CHANGED
@@ -1,3 +1,5 @@
1
+ "use strict";
2
+
1
3
  const fs = require("fs");
2
4
  const os = require("os");
3
5
  const path = require("path");
@@ -25,6 +27,7 @@ const RUNTIME_FILES = [
25
27
  "fit.js",
26
28
  "log.js",
27
29
  "payload-dump.js",
30
+ "usage.js",
28
31
  path.join("layouts", "lines.js"),
29
32
  path.join("layouts", "pills.js"),
30
33
  ];
@@ -34,10 +37,17 @@ const RUNTIME_FILES = [
34
37
  // machine can't read them (Windows profile folders are private already).
35
38
  const PRIVATE_FILES = [
36
39
  "statusline.config.json",
40
+ // The config before the last import (BACKUP_SUFFIX in cli/import.js).
41
+ "statusline.config.json.bak",
37
42
  ".exec-cache.json",
38
43
  "last-payload.json",
39
44
  // SYNC_CACHE_NAME in git.js: commit IDs of the repos you work in.
40
45
  ".git-sync-cache.json",
46
+ // STATUS_CACHE_NAME in git.js: repo paths with their file and line counts.
47
+ ".git-status-cache.json",
48
+ // USAGE_CACHE_NAME in usage.js: tokens per day, keyed by a hash of each
49
+ // conversation database's path.
50
+ ".usage-cache.json",
41
51
  // LOG_NAME in log.js, spelled out because log.js requires this file.
42
52
  "agy-powerline.log",
43
53
  ];
@@ -238,15 +248,54 @@ function settingsBackupPath(file = AGY_SETTINGS) {
238
248
  return `${file}.agy-powerline.bak`;
239
249
  }
240
250
 
241
- // Backs up the current file, then writes atomically.
242
- function writeJsonSafely(file, data) {
243
- fs.mkdirSync(path.dirname(file), { recursive: true });
251
+ function settingsOriginalBackupPath(file = AGY_SETTINGS) {
252
+ return `${file}.agy-powerline.original.bak`;
253
+ }
244
254
 
245
- if (fs.existsSync(file)) {
246
- fs.copyFileSync(file, settingsBackupPath(file));
255
+ // Two copies before every change: the latest file, replaced each time, and
256
+ // the file as it was before agy-powerline first changed it, written once and
257
+ // never replaced, so running init again can't lose the user's own version.
258
+ // Returns the original backup's path when this call created it.
259
+ function backUpSettings(file) {
260
+ if (!fs.existsSync(file)) {
261
+ return null;
262
+ }
263
+ const original = settingsOriginalBackupPath(file);
264
+ const created = !fs.existsSync(original);
265
+ if (created) {
266
+ fs.copyFileSync(file, original);
247
267
  }
268
+ fs.copyFileSync(file, settingsBackupPath(file));
269
+ return created ? original : null;
270
+ }
248
271
 
272
+ // Backs up the current file, then writes atomically. Returns what
273
+ // backUpSettings() returns.
274
+ function writeJsonSafely(file, data) {
275
+ fs.mkdirSync(path.dirname(file), { recursive: true });
276
+ const originalBackup = backUpSettings(file);
249
277
  writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
278
+ return originalBackup;
279
+ }
280
+
281
+ // Dotfile managers (stow, chezmoi, yadm) link files such as settings.json
282
+ // into place. A write goes through the link to the file it points to, so the
283
+ // link stays a link and the user's real file gets the change; a rename onto
284
+ // the link itself would replace it with a plain file.
285
+ function writeTarget(file) {
286
+ try {
287
+ return fs.realpathSync(file);
288
+ } catch {
289
+ // Missing file, or a link whose target doesn't exist yet: follow it once.
290
+ try {
291
+ if (fs.lstatSync(file).isSymbolicLink()) {
292
+ return path.resolve(path.dirname(file), fs.readlinkSync(file));
293
+ }
294
+ } catch {
295
+ // Nothing at that path.
296
+ }
297
+ return file;
298
+ }
250
299
  }
251
300
 
252
301
  // Writes via temp file + rename so an interrupted write cannot leave a
@@ -254,9 +303,10 @@ function writeJsonSafely(file, data) {
254
303
  // mode the existing file had, so rewriting a file never widens who can read
255
304
  // it (the rename would otherwise reset it to the default).
256
305
  function writeFileAtomic(file, body, { mode } = {}) {
257
- fs.mkdirSync(path.dirname(file), { recursive: true });
258
- const tmp = `${file}.${process.pid}.tmp`;
259
- const target = process.platform === "win32" ? undefined : (mode ?? existingMode(file));
306
+ const dest = writeTarget(file);
307
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
308
+ const tmp = `${dest}.${process.pid}.tmp`;
309
+ const target = process.platform === "win32" ? undefined : (mode ?? existingMode(dest));
260
310
  fs.writeFileSync(tmp, body, { encoding: "utf8", mode: target ?? 0o666 });
261
311
  if (target !== undefined) {
262
312
  // The mode passed to writeFileSync is reduced by the umask; set it exactly.
@@ -270,10 +320,10 @@ function writeFileAtomic(file, body, { mode } = {}) {
270
320
  }
271
321
 
272
322
  try {
273
- fs.renameSync(tmp, file);
323
+ fs.renameSync(tmp, dest);
274
324
  } catch {
275
325
  // Windows can refuse to replace a file another process holds open.
276
- fs.writeFileSync(file, body, "utf8");
326
+ fs.writeFileSync(dest, body, "utf8");
277
327
  fs.rmSync(tmp, { force: true });
278
328
  }
279
329
  }
@@ -301,11 +351,20 @@ function restrictPrivateFiles(installDir) {
301
351
  }
302
352
  }
303
353
 
354
+ // Removes a settings.json that would be left empty. A symlinked one is emptied
355
+ // instead: deleting the link would break the user's dotfiles and leave the
356
+ // real file unchanged. Returns "deleted", "emptied" or null (nothing there).
304
357
  function removeSettingsFile(file = AGY_SETTINGS) {
305
- if (fs.existsSync(file)) {
306
- fs.copyFileSync(file, settingsBackupPath(file));
307
- fs.rmSync(file, { force: true });
358
+ if (!fs.existsSync(file)) {
359
+ return null;
360
+ }
361
+ backUpSettings(file);
362
+ if (fs.lstatSync(file).isSymbolicLink()) {
363
+ writeFileAtomic(file, "{}\n");
364
+ return "emptied";
308
365
  }
366
+ fs.rmSync(file, { force: true });
367
+ return "deleted";
309
368
  }
310
369
 
311
370
  // Antigravity runs the Windows launcher through `cmd /c`, which splits the
@@ -368,6 +427,7 @@ module.exports = {
368
427
  readAgySettings,
369
428
  readSettingsForUpdate,
370
429
  settingsBackupPath,
430
+ settingsOriginalBackupPath,
371
431
  writeJsonSafely,
372
432
  writeFileAtomic,
373
433
  removeSettingsFile,
package/src/lib/prompt.js CHANGED
@@ -1,3 +1,5 @@
1
+ "use strict";
2
+
1
3
  const readline = require("readline");
2
4
 
3
5
  function createRl() {
@@ -24,8 +26,36 @@ async function confirm(rl, message, defaultYes) {
24
26
  return answer === "y" || answer === "yes";
25
27
  }
26
28
 
29
+ // Numbered options, answered with a number; Enter keeps the default. Anything
30
+ // else asks again, at most 3 times, then the default stands. No raw mode or
31
+ // arrow keys, so it works in every terminal and in cmd. An option can span
32
+ // several lines (a label and its preview). Returns the chosen index.
33
+ async function choose(rl, message, options, defaultIndex = 0, print = console.log) {
34
+ print(message);
35
+ options.forEach((option, i) => {
36
+ const [first, ...rest] = String(option).split("\n");
37
+ print(` ${String(i + 1).padStart(2)}) ${first}`);
38
+ rest.forEach((line) => print(` ${line}`));
39
+ });
40
+
41
+ for (let attempt = 0; attempt < 3; attempt++) {
42
+ const answer = (
43
+ await question(rl, ` Choose 1-${options.length} (Enter: ${defaultIndex + 1}) `)
44
+ ).trim();
45
+ if (!answer) {
46
+ return defaultIndex;
47
+ }
48
+ const index = /^\d{1,3}$/.test(answer) ? Number(answer) - 1 : -1;
49
+ if (index >= 0 && index < options.length) {
50
+ return index;
51
+ }
52
+ }
53
+ return defaultIndex;
54
+ }
55
+
27
56
  module.exports = {
28
57
  createRl,
29
58
  question,
30
59
  confirm,
60
+ choose,
31
61
  };
package/src/lib/run.js CHANGED
@@ -1,3 +1,5 @@
1
+ "use strict";
2
+
1
3
  const { spawn } = require("child_process");
2
4
 
3
5
  function spawnCapture(file, args, input, timeoutMs = 15000) {
@@ -2,7 +2,8 @@
2
2
 
3
3
  const os = require("os");
4
4
  const { formatReset, resetInSeconds, firstText } = require("./formatters");
5
- const { resolveGit } = require("./git");
5
+ const { resolveGit, resolveGitDetails } = require("./git");
6
+ const { resolveUsage } = require("./usage");
6
7
 
7
8
  const FIVE_HOURS = 5 * 3600;
8
9
  const SEVEN_DAYS = 7 * 86400;
@@ -144,7 +145,17 @@ function contextUsedPercent(data) {
144
145
  return (current * 100) / size;
145
146
  }
146
147
 
147
- function extractSession(data, config) {
148
+ // options.readUsage: the statusline itself may read agy's conversation
149
+ // databases; previews only use the usage cache.
150
+ function extractSession(data, config, options = {}) {
151
+ const git = resolveGit(data, config);
152
+ const gitDetails = resolveGitDetails(data, config);
153
+ // Once git status has been read for gitFiles, the branch's dirty marker can
154
+ // count uncommitted files too, not only an unfinished merge or rebase.
155
+ const files = gitDetails?.files;
156
+ if (git && files && gitDetails.branch === git.branch) {
157
+ git.dirty = git.dirty || files.staged + files.modified + files.conflicts > 0;
158
+ }
148
159
  const contextPct = percent(contextUsedPercent(data));
149
160
  const contextSize = count(data.context_window?.context_window_size);
150
161
  const current = data.context_window?.current_usage;
@@ -224,7 +235,10 @@ function extractSession(data, config) {
224
235
  version: firstText(data.version),
225
236
  sandbox: data.sandbox?.enabled === true,
226
237
  sandboxNet: data.sandbox?.allow_network === true,
227
- git: resolveGit(data, config),
238
+ git,
239
+ gitDetails,
240
+ // Only read when tokensToday or tokensWeek is on the bar.
241
+ usage: resolveUsage(config, { read: options.readUsage === true }),
228
242
  terminalWidth: terminalWidth(data.terminal_width ?? process.stdout.columns ?? 0),
229
243
  };
230
244
  }