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.
@@ -126,8 +126,45 @@ const WIDGET_CATALOG = [
126
126
  id: "git",
127
127
  label: "VCS Branch",
128
128
  unique: true,
129
- description: "current branch, marked when there are uncommitted changes",
130
- summary: "branch and uncommitted changes",
129
+ description:
130
+ "current branch, marked when there are uncommitted changes, with how many commits it is ahead of or behind its upstream",
131
+ summary: "branch, changes and ahead/behind",
132
+ },
133
+ {
134
+ id: "gitChanges",
135
+ label: "Git Line Changes",
136
+ unique: true,
137
+ description:
138
+ "lines added and removed since the last commit, staged or not (new files that aren't staged don't count)",
139
+ summary: "lines added and removed",
140
+ },
141
+ {
142
+ id: "gitFiles",
143
+ label: "Git File Counts",
144
+ unique: true,
145
+ description: "how many files are staged, modified, untracked and in conflict",
146
+ summary: "staged, modified, untracked file counts",
147
+ },
148
+ {
149
+ id: "gitSha",
150
+ label: "Git Commit",
151
+ unique: true,
152
+ description: "short ID of the commit checked out",
153
+ summary: "short commit ID",
154
+ },
155
+ {
156
+ id: "gitStash",
157
+ label: "Git Stash Count",
158
+ unique: true,
159
+ description: "how many stashes the repository holds; hidden when there are none",
160
+ summary: "number of stashes",
161
+ },
162
+ {
163
+ id: "gitWorktree",
164
+ label: "Git Worktree",
165
+ unique: true,
166
+ description: "name of the linked worktree you're in; hidden in the main one",
167
+ summary: "linked worktree name",
131
168
  },
132
169
  {
133
170
  id: "plan",
@@ -220,6 +257,21 @@ const WIDGET_CATALOG = [
220
257
  description: "session tokens, as a total or split into input and output",
221
258
  summary: "session tokens, total or split",
222
259
  },
260
+ {
261
+ id: "tokensToday",
262
+ label: "Tokens Today",
263
+ unique: true,
264
+ description: "tokens used today in all agy conversations, from agy's local data (Node 22.13+)",
265
+ summary: "tokens used today, all conversations",
266
+ },
267
+ {
268
+ id: "tokensWeek",
269
+ label: "Tokens 7 Days",
270
+ unique: true,
271
+ description:
272
+ "tokens used in the last 7 days in all agy conversations, from agy's local data (Node 22.13+)",
273
+ summary: "tokens used over 7 days",
274
+ },
223
275
  {
224
276
  id: "exec",
225
277
  label: "Shell Command",
package/src/lib/colors.js CHANGED
@@ -91,21 +91,24 @@ function rgb(r, g, b) {
91
91
  return [r, g, b];
92
92
  }
93
93
 
94
- function rgbToAnsi256([r, g, b]) {
95
- if (r === g && g === b) {
96
- if (r < 8) {
97
- return 16;
98
- }
99
-
100
- if (r > 248) {
101
- return 231;
102
- }
103
-
104
- return Math.round(((r - 8) / 247) * 24) + 232;
105
- }
94
+ // The nearest xterm color: the 6x6x6 cube's levels (0, 95, 135, ... 255) are
95
+ // not evenly spaced, and a near-gray reads better from the gray ramp, so both
96
+ // candidates are measured and the closer one wins (as tmux does).
97
+ const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
106
98
 
107
- const to6 = (v) => Math.round((v / 255) * 5);
108
- return 16 + 36 * to6(r) + 6 * to6(g) + to6(b);
99
+ function rgbToAnsi256([r, g, b]) {
100
+ const toCube = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.floor((v - 35) / 40));
101
+ const [cr, cg, cb] = [toCube(r), toCube(g), toCube(b)];
102
+ const cube = [CUBE_LEVELS[cr], CUBE_LEVELS[cg], CUBE_LEVELS[cb]];
103
+
104
+ const average = (r + g + b) / 3;
105
+ const grayIndex = average > 238 ? 23 : Math.max(0, Math.floor((average - 3) / 10));
106
+ const level = 8 + 10 * grayIndex;
107
+ const distance = ([x, y, z]) => (x - r) ** 2 + (y - g) ** 2 + (z - b) ** 2;
108
+
109
+ return distance([level, level, level]) < distance(cube)
110
+ ? 232 + grayIndex
111
+ : 16 + 36 * cr + 6 * cg + cb;
109
112
  }
110
113
 
111
114
  function colorSeq(kind, [r, g, b]) {
@@ -196,6 +199,122 @@ function parseColor(value, fallback = [200, 200, 200]) {
196
199
  return fallback;
197
200
  }
198
201
 
202
+ function linear(c) {
203
+ const v = c / 255;
204
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
205
+ }
206
+
207
+ function gamma(v) {
208
+ const c = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
209
+ return Math.max(0, Math.min(255, Math.round(c * 255)));
210
+ }
211
+
212
+ // WCAG relative luminance and contrast ratio.
213
+ function luminance([r, g, b]) {
214
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
215
+ }
216
+
217
+ function contrastRatio(a, b) {
218
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
219
+ return (hi + 0.05) / (lo + 0.05);
220
+ }
221
+
222
+ // OKLab (Björn Ottosson): mixing there keeps the middle of a gradient as
223
+ // bright and saturated as its ends, where sRGB mixing turns it muddy.
224
+ function toOklab([r, g, b]) {
225
+ const [lr, lg, lb] = [linear(r), linear(g), linear(b)];
226
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
227
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
228
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
229
+ return [
230
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
231
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
232
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
233
+ ];
234
+ }
235
+
236
+ function fromOklab([L, a, b]) {
237
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
238
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
239
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
240
+ return [
241
+ gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
242
+ gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
243
+ gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
244
+ ];
245
+ }
246
+
247
+ const MAX_GRADIENT_STOPS = 6;
248
+ const HEX_STOP = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
249
+
250
+ // "#ff5f6d-#ffc371" (2 to 6 hex stops, "#" optional) to RGB stops, or null.
251
+ function parseGradientStops(spec) {
252
+ if (typeof spec !== "string") {
253
+ return null;
254
+ }
255
+ const parts = spec.trim().split("-");
256
+ if (parts.length < 2 || parts.length > MAX_GRADIENT_STOPS) {
257
+ return null;
258
+ }
259
+ if (!parts.every((part) => HEX_STOP.test(part))) {
260
+ return null;
261
+ }
262
+ return parts.map((part) => parseColor(part));
263
+ }
264
+
265
+ // The color t (0 to 1) of the way along the stops; the ends are exact.
266
+ function gradientAt(stops, t) {
267
+ const clamped = Math.max(0, Math.min(1, Number(t) || 0));
268
+ const span = clamped * (stops.length - 1);
269
+ const i = Math.min(stops.length - 2, Math.floor(span));
270
+ const local = span - i;
271
+ if (local === 0) {
272
+ return [...stops[i]];
273
+ }
274
+ if (local === 1) {
275
+ return [...stops[i + 1]];
276
+ }
277
+ const [a, b] = [toOklab(stops[i]), toOklab(stops[i + 1])];
278
+ return fromOklab(a.map((value, k) => value + (b[k] - value) * local));
279
+ }
280
+
281
+ // count evenly spaced colors from the first stop to the last.
282
+ function gradientColors(stops, count) {
283
+ if (count <= 1) {
284
+ return count === 1 ? [[...stops[0]]] : [];
285
+ }
286
+ return Array.from({ length: count }, (_, i) => gradientAt(stops, i / (count - 1)));
287
+ }
288
+
289
+ const MIN_CONTRAST = 4.5;
290
+
291
+ // The preferred text color if it reads on the background, else whichever of
292
+ // the fallbacks contrasts most.
293
+ function readableText(background, preferred, fallbacks) {
294
+ if (contrastRatio(preferred, background) >= MIN_CONTRAST) {
295
+ return preferred;
296
+ }
297
+ return fallbacks.reduce((best, color) =>
298
+ contrastRatio(color, background) > contrastRatio(best, background) ? color : best
299
+ );
300
+ }
301
+
302
+ // The color moved toward white (on a dark background) or black (on a light
303
+ // one) just far enough to reach the minimum contrast.
304
+ function withContrast(color, background, min = MIN_CONTRAST) {
305
+ if (contrastRatio(color, background) >= min) {
306
+ return color;
307
+ }
308
+ const target = luminance(background) < 0.5 ? [255, 255, 255] : [0, 0, 0];
309
+ for (let step = 1; step <= 20; step++) {
310
+ const moved = mix(color, target, step / 20);
311
+ if (contrastRatio(moved, background) >= min) {
312
+ return moved;
313
+ }
314
+ }
315
+ return target;
316
+ }
317
+
199
318
  module.exports = {
200
319
  CSI,
201
320
  RESET,
@@ -221,4 +340,15 @@ module.exports = {
221
340
  shade,
222
341
  mix,
223
342
  parseColor,
343
+ luminance,
344
+ contrastRatio,
345
+ toOklab,
346
+ fromOklab,
347
+ MAX_GRADIENT_STOPS,
348
+ parseGradientStops,
349
+ gradientAt,
350
+ gradientColors,
351
+ MIN_CONTRAST,
352
+ readableText,
353
+ withContrast,
224
354
  };
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+
3
+ const { defaultConfig, canonicalThemeId, LAYOUTS } = require("./shared");
4
+ const { THEMES, normalizeGradient } = require("./themes");
5
+ const { VALID_WIDGET_IDS } = require("./config");
6
+ const { COLOR_MODES } = require("./colors");
7
+ const { isMeterStyle } = require("./widget-style");
8
+
9
+ const KNOWN_KEYS = new Set([
10
+ ...Object.keys(defaultConfig()),
11
+ "customTheme",
12
+ "gradient",
13
+ "meterStyle",
14
+ ]);
15
+
16
+ // Control characters shown as \uXXXX: the text is printed, never interpreted
17
+ // by the terminal or the browser.
18
+ function visible(text) {
19
+ return String(text).replace(
20
+ /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202A-\u202E\u2066-\u2069]/g,
21
+ (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
22
+ );
23
+ }
24
+
25
+ // Settings the raw config has that the bar can't use, so they don't come over
26
+ // on import or in the web configurator.
27
+ function droppedSettings(raw) {
28
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
29
+ return ["config is not an object"];
30
+ }
31
+
32
+ const dropped = [];
33
+ for (const key of Object.keys(raw)) {
34
+ if (!KNOWN_KEYS.has(key)) {
35
+ dropped.push(`unknown setting "${visible(key)}"`);
36
+ }
37
+ }
38
+ if (Array.isArray(raw.widgets)) {
39
+ for (const widget of raw.widgets) {
40
+ if (!widget || typeof widget !== "object" || !VALID_WIDGET_IDS.has(widget.id)) {
41
+ dropped.push(`unknown widget ${visible(JSON.stringify(widget?.id ?? widget) ?? "")}`);
42
+ }
43
+ }
44
+ } else if (raw.widgets !== undefined) {
45
+ dropped.push("widgets (not a list)");
46
+ }
47
+ if (raw.theme !== undefined && !THEMES[canonicalThemeId(raw.theme)]) {
48
+ dropped.push(`unknown theme ${visible(JSON.stringify(raw.theme))}`);
49
+ }
50
+ if (raw.layout !== undefined && !LAYOUTS[raw.layout]) {
51
+ dropped.push(`unknown layout ${visible(JSON.stringify(raw.layout))}`);
52
+ }
53
+ if (raw.color !== undefined && !COLOR_MODES.includes(raw.color)) {
54
+ dropped.push(`unknown color mode ${visible(JSON.stringify(raw.color))}`);
55
+ }
56
+ if (
57
+ raw.gradient !== undefined &&
58
+ raw.gradient !== null &&
59
+ raw.gradient !== "off" &&
60
+ !normalizeGradient(raw.gradient)
61
+ ) {
62
+ dropped.push(`invalid gradient ${visible(JSON.stringify(raw.gradient))}`);
63
+ }
64
+ if (raw.meterStyle !== undefined && !isMeterStyle(raw.meterStyle)) {
65
+ dropped.push(`unknown meter style ${visible(JSON.stringify(raw.meterStyle))}`);
66
+ }
67
+ return dropped;
68
+ }
69
+
70
+ module.exports = {
71
+ KNOWN_KEYS,
72
+ visible,
73
+ droppedSettings,
74
+ };
package/src/lib/config.js CHANGED
@@ -3,10 +3,11 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const { resolveColorModeId } = require("./colors");
6
- const { resolveThemeId } = require("./themes");
6
+ const { resolveThemeId, normalizeGradient } = require("./themes");
7
7
  const { stripControl, normalizeClock } = require("./formatters");
8
8
  const { normalizePowerline } = require("./powerline");
9
9
  const { writeFileAtomic, PRIVATE_FILE_MODE } = require("./paths");
10
+ const { isMeterStyle } = require("./widget-style");
10
11
 
11
12
  // Display text in widget configs is printed as-is, so strip terminal control
12
13
  // from it. cmd/command are left alone: they are executed, not printed.
@@ -39,6 +40,11 @@ const VALID_WIDGET_IDS = new Set([
39
40
  "cwd",
40
41
  "project",
41
42
  "git",
43
+ "gitChanges",
44
+ "gitFiles",
45
+ "gitSha",
46
+ "gitStash",
47
+ "gitWorktree",
42
48
  "plan",
43
49
  "agentState",
44
50
  "artifacts",
@@ -52,6 +58,8 @@ const VALID_WIDGET_IDS = new Set([
52
58
  "cacheCreate",
53
59
  "cacheHit",
54
60
  "totalTokens",
61
+ "tokensToday",
62
+ "tokensWeek",
55
63
  "exec",
56
64
  "customText",
57
65
  "separator",
@@ -83,9 +91,14 @@ const LAYOUTS = {
83
91
  name: "Lines",
84
92
  description: "Plain colored text with dividers and dot meters",
85
93
  },
94
+ classic: {
95
+ id: "classic",
96
+ name: "Classic",
97
+ description: "Plain colored text with | dividers and █░│ block meters",
98
+ },
86
99
  };
87
100
 
88
- const LAYOUT_ORDER = ["pills", "lines"];
101
+ const LAYOUT_ORDER = ["pills", "lines", "classic"];
89
102
 
90
103
  function resolveLayoutId(layoutId) {
91
104
  return LAYOUTS[layoutId] ? layoutId : "pills";
@@ -175,6 +188,17 @@ function mergeConfig(raw) {
175
188
  result.customTheme = raw.customTheme;
176
189
  }
177
190
 
191
+ // An unknown meter style is left out: each layout then draws its own.
192
+ if (isMeterStyle(raw?.meterStyle)) {
193
+ result.meterStyle = raw.meterStyle;
194
+ }
195
+
196
+ // Only a valid gradient is kept; anything else renders the plain theme.
197
+ const gradient = normalizeGradient(raw?.gradient);
198
+ if (gradient) {
199
+ result.gradient = gradient;
200
+ }
201
+
178
202
  return result;
179
203
  }
180
204
 
@@ -236,11 +260,12 @@ function saveConfig(config) {
236
260
  }
237
261
 
238
262
  // Presets set the widget list only; the layout (Pills or Lines) stays the one
239
- // the user picked. Each keeps all its widgets at 80 columns (full at 100).
263
+ // the user picked. Each answers one question and keeps all its widgets at 80
264
+ // columns (full at 100); widgets with nothing to show hide on their own.
240
265
  const PRESETS = {
241
266
  default: {
242
267
  id: "default",
243
- name: "Everyday",
268
+ name: "Default",
244
269
  description: "Model, context and git; 5h and 7d quotas with resets on a second row",
245
270
  widgets: [
246
271
  { id: "model" },
@@ -258,41 +283,84 @@ const PRESETS = {
258
283
  },
259
284
  compact: {
260
285
  id: "compact",
261
- name: "One line",
262
- description: "Short model, tightest quota, context % and git on a single row",
286
+ name: "Compact",
287
+ description: "One row: short model, tightest quota, context % and git",
263
288
  widgets: [{ id: "model", short: true }, { id: "quota" }, { id: "contextPct" }, { id: "git" }],
264
289
  },
265
- workspace: {
266
- id: "workspace",
267
- name: "Several repos",
268
- description: "Project, git, agent state and model; context, tightest quota and mode below",
290
+ agent: {
291
+ id: "agent",
292
+ name: "Agent activity",
293
+ description: "Agent state, subagents, title and mode; context, artifacts, quota",
294
+ widgets: [
295
+ { id: "agentState" },
296
+ { id: "tasks" },
297
+ { id: "title" },
298
+ { id: "separator" },
299
+ { id: "mode" },
300
+ { id: "sandbox" },
301
+ { id: "lineBreak" },
302
+ { id: "model", short: true },
303
+ { id: "contextPct" },
304
+ { id: "artifacts" },
305
+ { id: "separator" },
306
+ { id: "quota" },
307
+ ],
308
+ },
309
+ git: {
310
+ id: "git",
311
+ name: "Git focus",
312
+ description: "Project, branch, worktree, stash; changed files and lines, commit",
269
313
  widgets: [
270
314
  { id: "project" },
271
315
  { id: "git" },
272
- { id: "agentState" },
316
+ { id: "gitWorktree" },
317
+ { id: "gitStash" },
273
318
  { id: "separator" },
274
319
  { id: "model", short: true },
275
320
  { id: "lineBreak" },
276
- { id: "context" },
321
+ { id: "gitFiles" },
322
+ { id: "gitChanges" },
323
+ { id: "gitSha" },
277
324
  { id: "separator" },
325
+ { id: "contextPct" },
278
326
  { id: "quota" },
279
- { id: "mode" },
280
327
  ],
281
328
  },
282
- limits: {
283
- id: "limits",
284
- name: "Heavy usage",
285
- description: "5h, 7d, third-party and Argon quotas with resets, one quota per row",
329
+ tokens: {
330
+ id: "tokens",
331
+ name: "Token usage",
332
+ description: "Session tokens in and out, cache hit; tokens today and over 7 days",
286
333
  widgets: [
334
+ { id: "model", short: true },
335
+ { id: "contextTokens" },
336
+ { id: "separator" },
337
+ { id: "totalTokens", split: true },
338
+ { id: "cacheHit" },
339
+ { id: "lineBreak" },
340
+ { id: "tokensToday" },
341
+ { id: "tokensWeek" },
342
+ { id: "separator" },
343
+ { id: "quota" },
344
+ ],
345
+ },
346
+ quotas: {
347
+ id: "quotas",
348
+ name: "Quota watch",
349
+ description: "Plan and model; 5h and 7d quotas side by side; third-party and Argon",
350
+ widgets: [
351
+ { id: "plan" },
352
+ { id: "separator" },
353
+ { id: "model", short: true },
354
+ { id: "lineBreak" },
287
355
  { id: "fiveHourBar" },
288
356
  { id: "fiveHourReset" },
289
- { id: "lineBreak" },
357
+ { id: "separator" },
290
358
  { id: "weeklyBar" },
291
359
  { id: "weeklyReset" },
292
360
  { id: "lineBreak" },
293
361
  { id: "thirdPartyFiveHour" },
294
362
  { id: "thirdPartyWeekly" },
295
- { id: "lineBreak" },
363
+ { id: "separator" },
296
364
  { id: "argonWeekly" },
297
365
  { id: "argonReset" },
298
366
  ],
@@ -300,7 +368,7 @@ const PRESETS = {
300
368
  full: {
301
369
  id: "full",
302
370
  name: "Everything",
303
- description: "Session, project and git; context, tokens and cache; both quotas, on three rows",
371
+ description: "Session, project and git; context, tokens and cache; both quotas",
304
372
  widgets: [
305
373
  { id: "agentState" },
306
374
  { id: "tasks" },
@@ -325,7 +393,7 @@ const PRESETS = {
325
393
  },
326
394
  };
327
395
 
328
- const PRESET_ORDER = ["default", "compact", "workspace", "limits", "full"];
396
+ const PRESET_ORDER = ["default", "compact", "agent", "git", "tokens", "quotas", "full"];
329
397
 
330
398
  function applyPreset(baseConfig, presetName) {
331
399
  const preset = PRESETS[presetName] || PRESETS.default;