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.
@@ -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
@@ -196,6 +196,122 @@ function parseColor(value, fallback = [200, 200, 200]) {
196
196
  return fallback;
197
197
  }
198
198
 
199
+ function linear(c) {
200
+ const v = c / 255;
201
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
202
+ }
203
+
204
+ function gamma(v) {
205
+ const c = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
206
+ return Math.max(0, Math.min(255, Math.round(c * 255)));
207
+ }
208
+
209
+ // WCAG relative luminance and contrast ratio.
210
+ function luminance([r, g, b]) {
211
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
212
+ }
213
+
214
+ function contrastRatio(a, b) {
215
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
216
+ return (hi + 0.05) / (lo + 0.05);
217
+ }
218
+
219
+ // OKLab (Björn Ottosson): mixing there keeps the middle of a gradient as
220
+ // bright and saturated as its ends, where sRGB mixing turns it muddy.
221
+ function toOklab([r, g, b]) {
222
+ const [lr, lg, lb] = [linear(r), linear(g), linear(b)];
223
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
224
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
225
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
226
+ return [
227
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
228
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
229
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
230
+ ];
231
+ }
232
+
233
+ function fromOklab([L, a, b]) {
234
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
235
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
236
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
237
+ return [
238
+ gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
239
+ gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
240
+ gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
241
+ ];
242
+ }
243
+
244
+ const MAX_GRADIENT_STOPS = 6;
245
+ const HEX_STOP = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
246
+
247
+ // "#ff5f6d-#ffc371" (2 to 6 hex stops, "#" optional) to RGB stops, or null.
248
+ function parseGradientStops(spec) {
249
+ if (typeof spec !== "string") {
250
+ return null;
251
+ }
252
+ const parts = spec.trim().split("-");
253
+ if (parts.length < 2 || parts.length > MAX_GRADIENT_STOPS) {
254
+ return null;
255
+ }
256
+ if (!parts.every((part) => HEX_STOP.test(part))) {
257
+ return null;
258
+ }
259
+ return parts.map((part) => parseColor(part));
260
+ }
261
+
262
+ // The color t (0 to 1) of the way along the stops; the ends are exact.
263
+ function gradientAt(stops, t) {
264
+ const clamped = Math.max(0, Math.min(1, Number(t) || 0));
265
+ const span = clamped * (stops.length - 1);
266
+ const i = Math.min(stops.length - 2, Math.floor(span));
267
+ const local = span - i;
268
+ if (local === 0) {
269
+ return [...stops[i]];
270
+ }
271
+ if (local === 1) {
272
+ return [...stops[i + 1]];
273
+ }
274
+ const [a, b] = [toOklab(stops[i]), toOklab(stops[i + 1])];
275
+ return fromOklab(a.map((value, k) => value + (b[k] - value) * local));
276
+ }
277
+
278
+ // count evenly spaced colors from the first stop to the last.
279
+ function gradientColors(stops, count) {
280
+ if (count <= 1) {
281
+ return count === 1 ? [[...stops[0]]] : [];
282
+ }
283
+ return Array.from({ length: count }, (_, i) => gradientAt(stops, i / (count - 1)));
284
+ }
285
+
286
+ const MIN_CONTRAST = 4.5;
287
+
288
+ // The preferred text color if it reads on the background, else whichever of
289
+ // the fallbacks contrasts most.
290
+ function readableText(background, preferred, fallbacks) {
291
+ if (contrastRatio(preferred, background) >= MIN_CONTRAST) {
292
+ return preferred;
293
+ }
294
+ return fallbacks.reduce((best, color) =>
295
+ contrastRatio(color, background) > contrastRatio(best, background) ? color : best
296
+ );
297
+ }
298
+
299
+ // The color moved toward white (on a dark background) or black (on a light
300
+ // one) just far enough to reach the minimum contrast.
301
+ function withContrast(color, background, min = MIN_CONTRAST) {
302
+ if (contrastRatio(color, background) >= min) {
303
+ return color;
304
+ }
305
+ const target = luminance(background) < 0.5 ? [255, 255, 255] : [0, 0, 0];
306
+ for (let step = 1; step <= 20; step++) {
307
+ const moved = mix(color, target, step / 20);
308
+ if (contrastRatio(moved, background) >= min) {
309
+ return moved;
310
+ }
311
+ }
312
+ return target;
313
+ }
314
+
199
315
  module.exports = {
200
316
  CSI,
201
317
  RESET,
@@ -221,4 +337,15 @@ module.exports = {
221
337
  shade,
222
338
  mix,
223
339
  parseColor,
340
+ luminance,
341
+ contrastRatio,
342
+ toOklab,
343
+ fromOklab,
344
+ MAX_GRADIENT_STOPS,
345
+ parseGradientStops,
346
+ gradientAt,
347
+ gradientColors,
348
+ MIN_CONTRAST,
349
+ readableText,
350
+ withContrast,
224
351
  };
@@ -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",
@@ -175,6 +183,17 @@ function mergeConfig(raw) {
175
183
  result.customTheme = raw.customTheme;
176
184
  }
177
185
 
186
+ // An unknown meter style is left out: each layout then draws its own.
187
+ if (isMeterStyle(raw?.meterStyle)) {
188
+ result.meterStyle = raw.meterStyle;
189
+ }
190
+
191
+ // Only a valid gradient is kept; anything else renders the plain theme.
192
+ const gradient = normalizeGradient(raw?.gradient);
193
+ if (gradient) {
194
+ result.gradient = gradient;
195
+ }
196
+
178
197
  return result;
179
198
  }
180
199
 
package/src/lib/exec.js CHANGED
@@ -5,7 +5,8 @@ const path = require("path");
5
5
  const os = require("os");
6
6
  const crypto = require("crypto");
7
7
  const { execSync } = require("child_process");
8
- const { stripControl } = require("./formatters");
8
+ const { stripControl, sanitizeDeep } = require("./formatters");
9
+ const { RESET, getActiveDepth } = require("./colors");
9
10
  const { logWarning } = require("./log");
10
11
  const { PRIVATE_FILE_MODE } = require("./paths");
11
12
 
@@ -92,16 +93,123 @@ function cacheKey(cmd, cwd = "") {
92
93
  .slice(0, 16);
93
94
  }
94
95
 
96
+ // The only escape sequence "colors": true keeps: SGR color and style. Colon
97
+ // forms (38:2::r:g:b) are dropped with the rest, since not every terminal reads them.
98
+ const SGR_SPLIT = /(\x1b\[[0-9;]*m)/;
99
+ const SGR_ALL = /\x1b\[([0-9;]*)m/g;
100
+
95
101
  // Command output is untrusted text: strip terminal control and cap length.
96
- function sanitizeExecOutput(text) {
97
- const clean = stripControl(text).trim();
98
- const chars = Array.from(clean);
102
+ // With colors, SGR sequences survive and the visible text alone is capped;
103
+ // the output then ends with a reset so its colors can't leak past it.
104
+ function sanitizeExecOutput(text, { colors = false } = {}) {
105
+ if (!colors) {
106
+ const clean = stripControl(text).trim();
107
+ const chars = Array.from(clean);
108
+
109
+ if (chars.length <= MAX_OUTPUT_CHARS) {
110
+ return clean;
111
+ }
112
+
113
+ return chars.slice(0, MAX_OUTPUT_CHARS - 1).join("") + "…";
114
+ }
115
+
116
+ const parts = String(text ?? "")
117
+ .split(SGR_SPLIT)
118
+ .map((part, i) => (i % 2 ? part : stripControl(part)));
119
+ const visible = parts.filter((_, i) => i % 2 === 0).join("");
120
+ const lead = visible.length - visible.trimStart().length;
121
+ const chars = Array.from(visible.trim());
122
+ const cut = chars.length > MAX_OUTPUT_CHARS;
123
+ const budget = cut ? MAX_OUTPUT_CHARS - 1 : chars.length;
124
+ let skip = lead;
125
+ let used = 0;
126
+ let out = "";
127
+ let styled = false;
128
+
129
+ for (let i = 0; i < parts.length && used < budget; i++) {
130
+ if (i % 2) {
131
+ out += parts[i];
132
+ styled = true;
133
+ continue;
134
+ }
135
+ for (const char of Array.from(parts[i])) {
136
+ if (skip > 0) {
137
+ skip -= char.length;
138
+ continue;
139
+ }
140
+ if (used === budget) {
141
+ break;
142
+ }
143
+ out += char;
144
+ used++;
145
+ }
146
+ }
99
147
 
100
- if (chars.length <= MAX_OUTPUT_CHARS) {
101
- return clean;
148
+ return out + (cut ? "…" : "") + (styled ? RESET : "");
149
+ }
150
+
151
+ // After a reset in colored output, the widget's own colors come back (the
152
+ // pill's background and text in Pills), so the rest of the widget isn't left
153
+ // in the terminal's default colors.
154
+ function restoreAfterReset(text, restore) {
155
+ if (!restore) {
156
+ return text;
102
157
  }
158
+ return String(text).replace(SGR_ALL, (seq, body) => {
159
+ const params = body === "" ? [0] : body.split(";").map((p) => (p === "" ? 0 : Number(p)));
160
+ let lastReset = -1;
161
+ for (let i = 0; i < params.length; i++) {
162
+ // 38/48/58 take a color as their next parameters; a 0 there isn't a reset.
163
+ if (params[i] === 38 || params[i] === 48 || params[i] === 58) {
164
+ i += params[i + 1] === 5 ? 2 : params[i + 1] === 2 ? 4 : 0;
165
+ } else if (params[i] === 0) {
166
+ lastReset = i;
167
+ }
168
+ }
169
+ if (lastReset < 0) {
170
+ return seq;
171
+ }
172
+ const rest = params.slice(lastReset + 1);
173
+ return `${RESET}${restore}${rest.length ? `\x1b[${rest.join(";")}m` : ""}`;
174
+ });
175
+ }
176
+
177
+ // Never handed to a command: agy-powerline doesn't read the account email or
178
+ // the session and conversation IDs, so it doesn't pass them on either.
179
+ const PRIVATE_KEYS = new Set(["email", "session_id", "conversation_id"]);
180
+
181
+ function withoutPrivateKeys(value, depth = 0) {
182
+ if (value === null || typeof value !== "object") {
183
+ return value;
184
+ }
185
+ if (depth > 8) {
186
+ return undefined;
187
+ }
188
+ if (Array.isArray(value)) {
189
+ return value.map((item) => withoutPrivateKeys(item, depth + 1));
190
+ }
191
+ const out = {};
192
+ for (const [key, item] of Object.entries(value)) {
193
+ if (!PRIVATE_KEYS.has(key)) {
194
+ out[key] = withoutPrivateKeys(item, depth + 1);
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ // The session JSON an exec widget with "payload": true reads on stdin.
201
+ function execPayloadJson(payload) {
202
+ try {
203
+ return JSON.stringify(sanitizeDeep(withoutPrivateKeys(payload ?? {}))) ?? "{}";
204
+ } catch {
205
+ return "{}";
206
+ }
207
+ }
103
208
 
104
- return chars.slice(0, MAX_OUTPUT_CHARS - 1).join("") + "…";
209
+ // Widgets that share a command but not its options must not share its output.
210
+ function widgetCacheKey(widget, cwd) {
211
+ const variant = `${widget.payload === true ? "\0payload" : ""}${widget.colors === true ? "\0colors" : ""}`;
212
+ return cacheKey(`${String(widget.cmd).trim()}${variant}`, cwd);
105
213
  }
106
214
 
107
215
  // session.execMode: "run" executes the command, "blocked" marks a config the
@@ -115,11 +223,12 @@ function execWidgetOutput(widget, session = {}) {
115
223
  // Fitting the bar to the terminal can draw a row several times; a command
116
224
  // still runs at most once per refresh, even with "interval": 0.
117
225
  const memo = session.execMemo;
118
- const key = cacheKey(widget.cmd, session.cwd || process.cwd());
226
+ const key = widgetCacheKey(widget, session.cwd || process.cwd());
119
227
  if (memo?.has(key)) {
120
228
  return memo.get(key);
121
229
  }
122
- const output = sanitizeExecOutput(runExecWidget(widget, session));
230
+ const colors = widget.colors === true && getActiveDepth() !== "none";
231
+ const output = sanitizeExecOutput(runExecWidget(widget, session), { colors });
123
232
  memo?.set(key, output);
124
233
  return output;
125
234
  }
@@ -134,14 +243,15 @@ function runExecWidget(widget, session = {}) {
134
243
  }
135
244
 
136
245
  const cwd = session.cwd || process.cwd();
137
- const key = cacheKey(cmd, cwd);
246
+ const key = widgetCacheKey(widget, cwd);
138
247
  const now = Date.now();
248
+ const input = widget.payload === true ? `${session.execPayload || "{}"}\n` : undefined;
139
249
 
140
250
  let intervalMs = DEFAULT_INTERVAL_SEC * 1000;
141
251
  if (widget.interval != null) {
142
252
  const parsed = Number(widget.interval);
143
253
  if (Number.isFinite(parsed) && parsed >= 0) {
144
- intervalMs = parsed >= 1000 ? parsed : parsed * 1000;
254
+ intervalMs = parsed * 1000;
145
255
  }
146
256
  }
147
257
 
@@ -169,24 +279,30 @@ function runExecWidget(widget, session = {}) {
169
279
 
170
280
  // 3. Cache expired or missing: execute command with timeout
171
281
  let output = diskHit?.output || memHit?.output || "";
282
+ const firstLine = (stdout) =>
283
+ String(stdout || "")
284
+ .trim()
285
+ .split(/\r?\n/)[0]
286
+ ?.trim() || "";
172
287
  try {
173
288
  const stdout = execSync(cmd, {
174
289
  cwd,
175
290
  timeout: timeoutMs,
176
291
  encoding: "utf8",
177
- stdio: ["ignore", "pipe", "ignore"],
292
+ input,
293
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
178
294
  windowsHide: true,
179
295
  });
180
- output =
181
- String(stdout || "")
182
- .trim()
183
- .split(/\r?\n/)[0]
184
- ?.trim() || "";
296
+ output = firstLine(stdout);
185
297
  } catch (err) {
186
- // Keep the previous output. A non-zero exit is normal for many commands
187
- // (grep with no match), so only timeouts and missing commands are logged,
188
- // and never the command itself.
189
- if (err.code === "ETIMEDOUT" || err.signal === "SIGTERM") {
298
+ // A command that exits without reading the whole payload closes the pipe
299
+ // under the write; it still succeeded, so its output counts. Otherwise the
300
+ // previous output stays. A non-zero exit is normal for many commands (grep
301
+ // with no match), so only timeouts and missing commands are logged, and
302
+ // never the command itself.
303
+ if (input !== undefined && err.status === 0 && !err.signal) {
304
+ output = firstLine(err.stdout);
305
+ } else if (err.code === "ETIMEDOUT" || err.signal === "SIGTERM") {
190
306
  logWarning("EXEC_TIMEOUT", { timeoutMs });
191
307
  } else if (err.status === 127 || err.code === "ENOENT") {
192
308
  logWarning("EXEC_COMMAND_NOT_FOUND");
@@ -223,6 +339,9 @@ module.exports = {
223
339
  runExecWidget,
224
340
  execWidgetOutput,
225
341
  sanitizeExecOutput,
342
+ restoreAfterReset,
343
+ execPayloadJson,
344
+ widgetCacheKey,
226
345
  cacheKey,
227
346
  clearExecCache,
228
347
  getCacheFilePath,
package/src/lib/fit.js CHANGED
@@ -6,12 +6,16 @@ const { charWidth, visibleWidth } = require("./formatters");
6
6
  // first entry first. Ids not listed (custom ones) go before all of them.
7
7
  const DROP_ORDER = [
8
8
  "customText",
9
+ "gitStash",
10
+ "gitWorktree",
9
11
  "title",
10
12
  "version",
11
13
  "cacheCreate",
12
14
  "cacheRead",
13
15
  "totalTokens",
14
16
  "cacheHit",
17
+ "tokensToday",
18
+ "tokensWeek",
15
19
  "artifacts",
16
20
  "tasks",
17
21
  "plan",
@@ -28,6 +32,9 @@ const DROP_ORDER = [
28
32
  "weeklyReset",
29
33
  "fiveHourReset",
30
34
  "contextTokens",
35
+ "gitSha",
36
+ "gitFiles",
37
+ "gitChanges",
31
38
  "git",
32
39
  "sandbox",
33
40
  "weeklyPct",
@@ -271,6 +271,24 @@ function formatTokenSplit(input, output, ascii = false) {
271
271
  : `${formatTokens(input)}↑ ${formatTokens(output)}↓`;
272
272
  }
273
273
 
274
+ // The usage widgets' text: "1.2M", or "1.0M↑ 245.3K↓" with "split", plus the
275
+ // share of a "budget" when one is set ("1.2M (60%)"). null with no tokens.
276
+ function formatUsage(tokens, widget = {}, ascii = false) {
277
+ const input = Number(tokens?.input) || 0;
278
+ const output = Number(tokens?.output) || 0;
279
+ const total = input + output;
280
+ if (!(total > 0)) {
281
+ return null;
282
+ }
283
+ const value = widget?.split ? formatTokenSplit(input, output, ascii) : formatTokens(total);
284
+ const budget = widget?.budget;
285
+ const pct =
286
+ typeof budget === "number" && Number.isFinite(budget) && budget > 0
287
+ ? (total * 100) / budget
288
+ : null;
289
+ return { text: pct === null ? value : `${value} (${Math.round(pct)}%)`, pct };
290
+ }
291
+
274
292
  // "used/window", or just the used tokens while the window size is unknown.
275
293
  function formatContextTokens(used, size) {
276
294
  return size > 0 ? `${formatTokens(used)}/${formatTokens(size)}` : formatTokens(used);
@@ -460,6 +478,31 @@ function visibleWidth(text) {
460
478
  return width;
461
479
  }
462
480
 
481
+ // "+128 −34" as parts, so Lines can color each; zero counts are left out.
482
+ function gitChangeParts(changes, ascii = false) {
483
+ const parts = [];
484
+ if (changes?.added > 0) {
485
+ parts.push({ kind: "added", text: `+${changes.added}` });
486
+ }
487
+ if (changes?.removed > 0) {
488
+ parts.push({ kind: "removed", text: `${ascii ? "-" : "−"}${changes.removed}` });
489
+ }
490
+ return parts;
491
+ }
492
+
493
+ const GIT_FILE_MARKS = {
494
+ unicode: { staged: "+", modified: "●", untracked: "?", conflicts: "≠" },
495
+ ascii: { staged: "S", modified: "M", untracked: "?", conflicts: "!" },
496
+ };
497
+
498
+ // "+2 ●3 ?1 ≠1" as parts: staged, modified, untracked, in conflict.
499
+ function gitFileParts(files, ascii = false) {
500
+ const marks = GIT_FILE_MARKS[ascii ? "ascii" : "unicode"];
501
+ return Object.keys(marks)
502
+ .filter((kind) => files?.[kind] > 0)
503
+ .map((kind) => ({ kind, text: `${marks[kind]}${files[kind]}` }));
504
+ }
505
+
463
506
  function formatGitSync(ahead, behind, ascii = false) {
464
507
  const up = ascii ? "^" : "⇡";
465
508
  const down = ascii ? "v" : "⇣";
@@ -477,6 +520,26 @@ function formatGitSync(ahead, behind, ascii = false) {
477
520
  return "";
478
521
  }
479
522
 
523
+ const GIT_OPERATION_LABELS = {
524
+ rebase: "REBASE",
525
+ am: "AM",
526
+ merge: "MERGING",
527
+ "cherry-pick": "CHERRY-PICKING",
528
+ revert: "REVERTING",
529
+ bisect: "BISECTING",
530
+ };
531
+
532
+ // "REBASE 2/5", "MERGING", or "" with no operation. Plain ASCII in both modes.
533
+ function formatGitOperation(op) {
534
+ const name = GIT_OPERATION_LABELS[op?.name];
535
+ if (!name) {
536
+ return "";
537
+ }
538
+ return Number.isInteger(op.step) && Number.isInteger(op.total)
539
+ ? `${name} ${op.step}/${op.total}`
540
+ : name;
541
+ }
542
+
480
543
  // "2 subagents, 1 task", or "" when nothing runs.
481
544
  function formatTasks(subagents, tasks) {
482
545
  const part = (n, word) => (n > 0 ? `${n} ${word}${n === 1 ? "" : "s"}` : "");
@@ -585,7 +648,11 @@ module.exports = {
585
648
  formatContextTokens,
586
649
  shortModelName,
587
650
  formatTokenSplit,
651
+ formatUsage,
588
652
  formatGitSync,
653
+ formatGitOperation,
654
+ gitChangeParts,
655
+ gitFileParts,
589
656
  formatTasks,
590
657
  formatMode,
591
658
  clipText,