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.
@@ -0,0 +1,524 @@
1
+ "use strict";
2
+
3
+ // Tokens used today and over the last 7 days, for the tokensToday and
4
+ // tokensWeek widgets. agy keeps each conversation in a SQLite database of its
5
+ // own; only the usage numbers, response IDs (to drop duplicates) and
6
+ // timestamps are read from it, never a message, a model prompt or a name.
7
+ // The field numbers and the way duplicates are dropped follow ccusage's
8
+ // Antigravity adapter (MIT, rust/adapters/antigravity/src/parser.rs).
9
+
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+ const crypto = require("crypto");
14
+ const { logWarning } = require("./log");
15
+ const { writeFileAtomic, PRIVATE_FILE_MODE } = require("./paths");
16
+
17
+ const USAGE_CACHE_NAME = ".usage-cache.json";
18
+ // Reading databases stops for this refresh past this budget; the cache keeps
19
+ // the totals and the rest is read on the next refresh.
20
+ const READ_BUDGET_MS = 100;
21
+ const DAY_MS = 86400000;
22
+ // How many days back each widget looks, today included.
23
+ const USAGE_PERIODS = { tokensToday: 1, tokensWeek: 7 };
24
+ // Usage metadata is a few hundred bytes; anything this big isn't.
25
+ const MAX_BLOB_BYTES = 4 * 1024 * 1024;
26
+ const MAX_DEPTH = 8;
27
+ const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
28
+
29
+ // Database opens, so tests can check that unchanged files aren't read again.
30
+ const stats = { opens: 0 };
31
+ let memoryCache = null;
32
+ let sqlite;
33
+
34
+ // node:sqlite is built into Node 22.13 / 23.4 and newer. Node 22 prints an
35
+ // ExperimentalWarning on stderr when it loads, which must not reach the bar's
36
+ // stderr: that one warning is swallowed while it loads, every other one goes on.
37
+ function loadSqlite() {
38
+ if (sqlite !== undefined) {
39
+ return sqlite;
40
+ }
41
+ const emitWarning = process.emitWarning;
42
+ process.emitWarning = function (warning, ...rest) {
43
+ const type = typeof rest[0] === "string" ? rest[0] : rest[0]?.type;
44
+ const name = type || warning?.name;
45
+ if (name === "ExperimentalWarning" && /sqlite/i.test(String(warning?.message ?? warning))) {
46
+ return undefined;
47
+ }
48
+ return emitWarning.call(this, warning, ...rest);
49
+ };
50
+ try {
51
+ sqlite = require("node:sqlite");
52
+ } catch {
53
+ sqlite = null;
54
+ } finally {
55
+ process.emitWarning = emitWarning;
56
+ }
57
+ return sqlite;
58
+ }
59
+
60
+ function usageAvailable() {
61
+ return Boolean(loadSqlite()?.DatabaseSync);
62
+ }
63
+
64
+ // A minimal protobuf reader: varints and length-delimited fields, fixed-width
65
+ // fields skipped. Truncated or oversized input, groups and nesting deeper
66
+ // than MAX_DEPTH throw; the database is then skipped for this refresh.
67
+ function readVarint(buf, pos) {
68
+ let value = 0;
69
+ for (let shift = 0; shift < 64; shift += 7) {
70
+ if (pos >= buf.length) {
71
+ throw new Error("truncated varint");
72
+ }
73
+ const byte = buf[pos++];
74
+ value += (byte & 0x7f) * 2 ** shift;
75
+ if (byte < 0x80) {
76
+ return [value, pos];
77
+ }
78
+ }
79
+ throw new Error("varint too long");
80
+ }
81
+
82
+ function decodeMessage(buf, depth = 0) {
83
+ if (depth > MAX_DEPTH) {
84
+ throw new Error("message nested too deep");
85
+ }
86
+ if (!(buf instanceof Uint8Array) || buf.length > MAX_BLOB_BYTES) {
87
+ throw new Error("not a protobuf message");
88
+ }
89
+ const fields = new Map();
90
+ let pos = 0;
91
+ while (pos < buf.length) {
92
+ let key;
93
+ [key, pos] = readVarint(buf, pos);
94
+ const field = Math.floor(key / 8);
95
+ const wire = key % 8;
96
+ let value;
97
+ if (wire === 0) {
98
+ [value, pos] = readVarint(buf, pos);
99
+ } else if (wire === 2) {
100
+ let length;
101
+ [length, pos] = readVarint(buf, pos);
102
+ if (pos + length > buf.length) {
103
+ throw new Error("truncated field");
104
+ }
105
+ value = buf.subarray(pos, pos + length);
106
+ pos += length;
107
+ } else if (wire === 1 || wire === 5) {
108
+ pos += wire === 1 ? 8 : 4;
109
+ if (pos > buf.length) {
110
+ throw new Error("truncated field");
111
+ }
112
+ continue;
113
+ } else {
114
+ throw new Error("unsupported wire type");
115
+ }
116
+ if (field < 1) {
117
+ throw new Error("bad field number");
118
+ }
119
+ if (!fields.has(field)) {
120
+ fields.set(field, []);
121
+ }
122
+ fields.get(field).push(value);
123
+ }
124
+ return { fields, depth };
125
+ }
126
+
127
+ function nested(msg, field) {
128
+ return (msg?.fields.get(field) ?? [])
129
+ .filter((value) => value instanceof Uint8Array)
130
+ .map((value) => decodeMessage(value, msg.depth + 1));
131
+ }
132
+
133
+ function child(msg, field) {
134
+ return nested(msg, field)[0] ?? null;
135
+ }
136
+
137
+ function number(msg, field) {
138
+ const value = msg?.fields.get(field)?.[0];
139
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
140
+ }
141
+
142
+ function text(msg, field) {
143
+ const value = msg?.fields.get(field)?.[0];
144
+ return value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : "";
145
+ }
146
+
147
+ // Timestamp message: 1 = seconds, 2 = nanoseconds.
148
+ function timestamp(msg) {
149
+ const seconds = number(msg, 1);
150
+ return seconds > 0 ? seconds * 1000 + Math.floor(number(msg, 2) / 1e6) : null;
151
+ }
152
+
153
+ // ModelUsage: 2 = input, 3 = total output; 11, 12 and 7 = response, provider
154
+ // message and message IDs, the first one present names the response.
155
+ function usageEntry(msg, ts) {
156
+ if (!msg) {
157
+ return null;
158
+ }
159
+ return {
160
+ input: number(msg, 2),
161
+ output: number(msg, 3),
162
+ id: text(msg, 11) || text(msg, 12) || text(msg, 7),
163
+ ts,
164
+ };
165
+ }
166
+
167
+ // gen_metadata.data: GeneratorMetadata. 1 = chat-model message, whose 4 is
168
+ // the usage, 17 the retries (each with its usage in 2) and 9 the generation
169
+ // info (timestamp in 4).
170
+ function generatorEntries(data) {
171
+ const chat = child(decodeMessage(data), 1);
172
+ const ts = timestamp(child(child(chat, 9), 4));
173
+ return [
174
+ usageEntry(child(chat, 4), ts),
175
+ ...nested(chat, 17).map((r) => usageEntry(child(r, 2), ts)),
176
+ ];
177
+ }
178
+
179
+ // steps.metadata: CortexStepMetadata. 9 = usage, 28 = retries (usage in 2),
180
+ // 8 or else 1 = timestamp.
181
+ function stepEntries(data) {
182
+ const step = decodeMessage(data);
183
+ const ts = timestamp(child(step, 8)) ?? timestamp(child(step, 1));
184
+ return [
185
+ usageEntry(child(step, 9), ts),
186
+ ...nested(step, 28).map((r) => usageEntry(child(r, 2), ts)),
187
+ ];
188
+ }
189
+
190
+ function blob(value) {
191
+ if (value === null || value === undefined) {
192
+ return null;
193
+ }
194
+ if (!(value instanceof Uint8Array)) {
195
+ throw new Error("not a blob");
196
+ }
197
+ return value;
198
+ }
199
+
200
+ const dayFormats = new Map();
201
+
202
+ // "2026-10-18" for a moment, in the time zone given (the machine's own when "").
203
+ function dayKey(ms, timeZone = "") {
204
+ if (!dayFormats.has(timeZone)) {
205
+ dayFormats.set(
206
+ timeZone,
207
+ new Intl.DateTimeFormat("en-US", {
208
+ timeZone: timeZone || undefined,
209
+ year: "numeric",
210
+ month: "2-digit",
211
+ day: "2-digit",
212
+ })
213
+ );
214
+ }
215
+ const parts = {};
216
+ for (const part of dayFormats.get(timeZone).formatToParts(new Date(ms))) {
217
+ parts[part.type] = part.value;
218
+ }
219
+ return `${parts.year}-${parts.month}-${parts.day}`;
220
+ }
221
+
222
+ // Today and the days before it, `count` in all. Half-day steps never skip a
223
+ // day, even across a daylight saving change.
224
+ function recentDays(now, count, timeZone) {
225
+ const keys = new Set();
226
+ for (let t = now; keys.size < count; t -= DAY_MS / 2) {
227
+ keys.add(dayKey(t, timeZone));
228
+ }
229
+ return [...keys];
230
+ }
231
+
232
+ // Input and output tokens per day in one conversation database.
233
+ function readDatabase(file, timeZone, fallbackMs) {
234
+ const db = new sqlite.DatabaseSync(file, { readOnly: true });
235
+ stats.opens += 1;
236
+ try {
237
+ const tables = new Set(
238
+ db
239
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
240
+ .all()
241
+ .map((row) => row.name)
242
+ );
243
+ if (!tables.has("gen_metadata")) {
244
+ throw new Error("no gen_metadata table");
245
+ }
246
+
247
+ // The conversation's own time, for rows that carry none.
248
+ let conversationTs = null;
249
+ if (tables.has("trajectory_metadata_blob")) {
250
+ for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").all()) {
251
+ const data = blob(row.data);
252
+ conversationTs = (data && timestamp(child(decodeMessage(data), 2))) ?? conversationTs;
253
+ }
254
+ }
255
+
256
+ const entries = [];
257
+ for (const row of db.prepare("SELECT idx, data FROM gen_metadata ORDER BY idx ASC").all()) {
258
+ const data = blob(row.data);
259
+ if (data) {
260
+ entries.push(...generatorEntries(data));
261
+ }
262
+ }
263
+ if (tables.has("steps")) {
264
+ for (const row of db.prepare("SELECT idx, metadata FROM steps ORDER BY idx ASC").all()) {
265
+ const data = blob(row.metadata);
266
+ if (data) {
267
+ entries.push(...stepEntries(data));
268
+ }
269
+ }
270
+ }
271
+
272
+ // The same response can be in both tables and among the retries.
273
+ const seen = new Set();
274
+ const days = {};
275
+ for (const entry of entries) {
276
+ if (!entry || entry.input + entry.output <= 0) {
277
+ continue;
278
+ }
279
+ if (entry.id) {
280
+ if (seen.has(entry.id)) {
281
+ continue;
282
+ }
283
+ seen.add(entry.id);
284
+ }
285
+ const day = dayKey(entry.ts ?? conversationTs ?? fallbackMs, timeZone);
286
+ days[day] = days[day] || { input: 0, output: 0 };
287
+ days[day].input += entry.input;
288
+ days[day].output += entry.output;
289
+ }
290
+ return days;
291
+ } finally {
292
+ db.close();
293
+ }
294
+ }
295
+
296
+ function conversationsDir() {
297
+ return path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
298
+ }
299
+
300
+ // agy may still have recent writes in the -wal file next to the database.
301
+ function signature(file) {
302
+ try {
303
+ const stat = fs.statSync(file);
304
+ if (!stat.isFile()) {
305
+ return null;
306
+ }
307
+ let mtimeMs = stat.mtimeMs;
308
+ let size = stat.size;
309
+ try {
310
+ const wal = fs.statSync(`${file}-wal`);
311
+ mtimeMs = Math.max(mtimeMs, wal.mtimeMs);
312
+ size += wal.size;
313
+ } catch {
314
+ // No write-ahead log.
315
+ }
316
+ return { mtimeMs, size };
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ // The file name is a conversation ID: the cache only keeps a hash of the path.
323
+ function fileKey(file) {
324
+ return crypto.createHash("sha256").update(file).digest("hex").slice(0, 16);
325
+ }
326
+
327
+ // Only next to a real install's config, like the git caches.
328
+ function cachePath() {
329
+ try {
330
+ const { getConfigDir } = require("./config");
331
+ const dir = getConfigDir();
332
+ return dir && fs.existsSync(path.join(dir, "statusline.config.json"))
333
+ ? path.join(dir, USAGE_CACHE_NAME)
334
+ : null;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ function validDays(days) {
341
+ return (
342
+ days !== null &&
343
+ typeof days === "object" &&
344
+ !Array.isArray(days) &&
345
+ Object.entries(days).every(
346
+ ([day, tokens]) =>
347
+ DAY_KEY.test(day) &&
348
+ ["input", "output"].every(
349
+ (key) => Number.isFinite(tokens?.[key]) && tokens[key] >= 0 && tokens[key] <= 1e15
350
+ )
351
+ )
352
+ );
353
+ }
354
+
355
+ function validEntry(entry) {
356
+ return Number.isFinite(entry?.mtimeMs) && Number.isFinite(entry?.size) && validDays(entry.days);
357
+ }
358
+
359
+ // The cache file can be edited or torn like any file: bad entries are dropped.
360
+ function readCache(file) {
361
+ let data = memoryCache;
362
+ if (file) {
363
+ try {
364
+ data = JSON.parse(fs.readFileSync(file, "utf8"));
365
+ } catch {
366
+ data = null;
367
+ }
368
+ }
369
+ const files = {};
370
+ if (data?.files && typeof data.files === "object") {
371
+ for (const [key, entry] of Object.entries(data.files)) {
372
+ if (/^[0-9a-f]{16}$/.test(key) && validEntry(entry)) {
373
+ files[key] = entry;
374
+ }
375
+ }
376
+ }
377
+ return { tz: typeof data?.tz === "string" ? data.tz : "", files };
378
+ }
379
+
380
+ function saveCache(file, cache) {
381
+ memoryCache = cache;
382
+ if (!file) {
383
+ return;
384
+ }
385
+ try {
386
+ writeFileAtomic(file, JSON.stringify(cache), { mode: PRIVATE_FILE_MODE });
387
+ } catch {
388
+ // Best effort: the next refresh reads the databases again.
389
+ }
390
+ }
391
+
392
+ // Reads the databases changed since the cache was written, within the time
393
+ // budget. Returns whether the cache changed.
394
+ function refresh(cache, periodDays, timeZone) {
395
+ let names;
396
+ try {
397
+ names = fs.readdirSync(conversationsDir());
398
+ } catch {
399
+ names = [];
400
+ }
401
+
402
+ const now = Date.now();
403
+ // A day of margin for time zones.
404
+ const cutoff = now - (periodDays + 1) * DAY_MS;
405
+ const files = {};
406
+ let changed = false;
407
+
408
+ for (const name of names) {
409
+ if (!name.endsWith(".db")) {
410
+ continue;
411
+ }
412
+ const file = path.join(conversationsDir(), name);
413
+ const sig = signature(file);
414
+ if (!sig || sig.mtimeMs < cutoff) {
415
+ continue;
416
+ }
417
+ const key = fileKey(file);
418
+ const old = cache.files[key];
419
+ if (old && old.mtimeMs === sig.mtimeMs && old.size === sig.size) {
420
+ files[key] = old;
421
+ continue;
422
+ }
423
+ if (Date.now() - now > READ_BUDGET_MS) {
424
+ if (old) {
425
+ files[key] = old;
426
+ }
427
+ continue;
428
+ }
429
+ try {
430
+ files[key] = { ...sig, days: readDatabase(file, timeZone, sig.mtimeMs) };
431
+ changed = true;
432
+ } catch (err) {
433
+ // Busy, locked, not agy's format: skipped until it changes again. No
434
+ // path or ID in the log.
435
+ const code = typeof err?.code === "string" && /^[A-Z_]{1,40}$/.test(err.code) ? err.code : "";
436
+ logWarning("USAGE_DB_UNREADABLE", code ? { code } : {});
437
+ if (old) {
438
+ files[key] = old;
439
+ }
440
+ }
441
+ }
442
+
443
+ changed = changed || Object.keys(files).length !== Object.keys(cache.files).length;
444
+ cache.files = files;
445
+ return changed;
446
+ }
447
+
448
+ function sum(cache, days) {
449
+ const total = { input: 0, output: 0 };
450
+ for (const entry of Object.values(cache.files)) {
451
+ for (const day of days) {
452
+ total.input += entry.days[day]?.input ?? 0;
453
+ total.output += entry.days[day]?.output ?? 0;
454
+ }
455
+ }
456
+ return total;
457
+ }
458
+
459
+ function machineZone() {
460
+ try {
461
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
462
+ } catch {
463
+ return "";
464
+ }
465
+ }
466
+
467
+ // { today, week } tokens ({ input, output } each), or null when no usage
468
+ // widget is on the bar or node:sqlite is missing. Only the statusline itself
469
+ // reads databases (read: true); previews use what the cache holds.
470
+ function resolveUsage(config, { read = false } = {}) {
471
+ const periodDays = Math.max(
472
+ 0,
473
+ ...(config?.widgets ?? []).map((widget) => USAGE_PERIODS[widget?.id] ?? 0)
474
+ );
475
+ if (!periodDays) {
476
+ return null;
477
+ }
478
+
479
+ try {
480
+ const timeZone = config?.clock?.timeZone || "";
481
+ // Days are counted in this zone, so a cache made in another one is stale.
482
+ const zone = timeZone || machineZone();
483
+ const file = cachePath();
484
+ let cache = readCache(file);
485
+ if (cache.tz !== zone) {
486
+ cache = { tz: zone, files: {} };
487
+ }
488
+
489
+ if (read) {
490
+ if (!usageAvailable()) {
491
+ return null;
492
+ }
493
+ if (refresh(cache, periodDays, timeZone)) {
494
+ saveCache(file, cache);
495
+ }
496
+ }
497
+
498
+ const now = Date.now();
499
+ return {
500
+ today: sum(cache, recentDays(now, 1, timeZone)),
501
+ week: sum(cache, recentDays(now, 7, timeZone)),
502
+ };
503
+ } catch {
504
+ return null;
505
+ }
506
+ }
507
+
508
+ function clearUsageCache() {
509
+ memoryCache = null;
510
+ }
511
+
512
+ module.exports = {
513
+ USAGE_CACHE_NAME,
514
+ USAGE_PERIODS,
515
+ READ_BUDGET_MS,
516
+ stats,
517
+ loadSqlite,
518
+ usageAvailable,
519
+ decodeMessage,
520
+ dayKey,
521
+ recentDays,
522
+ resolveUsage,
523
+ clearUsageCache,
524
+ };
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+
3
+ const { resolveQuotaThresholds, resolveContextThresholds } = require("./formatters");
4
+ const { METER_STYLE_IDS } = require("./widget-style");
5
+
6
+ // Widgets that take each option, as the layouts read them.
7
+ const QUOTA_WIDGETS = new Set([
8
+ "quota",
9
+ "fiveHourBar",
10
+ "fiveHourPct",
11
+ "weeklyBar",
12
+ "weeklyPct",
13
+ "thirdPartyFiveHour",
14
+ "thirdPartyWeekly",
15
+ "argonWeekly",
16
+ ]);
17
+ const PACED_WIDGETS = new Set([
18
+ "fiveHourBar",
19
+ "weeklyBar",
20
+ "thirdPartyFiveHour",
21
+ "thirdPartyWeekly",
22
+ "argonWeekly",
23
+ ]);
24
+ const CONTEXT_WIDGETS = new Set(["context", "contextPct", "contextTokens"]);
25
+ const METER_WIDGETS = new Set([...PACED_WIDGETS, "context"]);
26
+ const RESET_WIDGETS = new Set(["fiveHourReset", "weeklyReset", "argonReset"]);
27
+ const COLOR_SLOTS = ["model", "context", "git", "weekly", "thirdParty", "fiveHour", "meta"];
28
+
29
+ const onOff = (value) => (value ? "on" : "off");
30
+
31
+ // The options of one widget: cycle fields step through values, number fields
32
+ // step by 5 with the arrows, text fields take typing, and fixed ones only show
33
+ // their value. undefined means "not set": the widget follows the global
34
+ // setting or its default.
35
+ function editFields(widget, config = {}, options = {}) {
36
+ const quota = QUOTA_WIDGETS.has(widget.id);
37
+ const context = CONTEXT_WIDGETS.has(widget.id);
38
+ const thresholds = quota
39
+ ? resolveQuotaThresholds({}, config)
40
+ : context
41
+ ? resolveContextThresholds({}, config)
42
+ : null;
43
+
44
+ const colorShow = options.colorName
45
+ ? options.colorName
46
+ : (value) => (value === undefined ? "theme default" : String(value));
47
+
48
+ const fields = [
49
+ {
50
+ key: "color",
51
+ label: "Color",
52
+ values: [undefined, ...COLOR_SLOTS],
53
+ show: colorShow,
54
+ help: "Draws this widget in another of the theme's colors. Caution and danger colors still take over when a quota or the context runs low.",
55
+ },
56
+ {
57
+ key: "raw",
58
+ label: "Label",
59
+ values: [undefined, false, true],
60
+ show: (value) =>
61
+ value === undefined
62
+ ? `default (${config.minimalist ? "hidden" : "shown"})`
63
+ : value
64
+ ? "hidden, value only"
65
+ : "shown",
66
+ help: "Shows or hides the icon and label before the value. Default follows minimalist mode (m).",
67
+ },
68
+ ];
69
+
70
+ if (widget.id === "model") {
71
+ fields.push({
72
+ key: "short",
73
+ label: "Short name",
74
+ values: [undefined, true],
75
+ show: (value) => onOff(value),
76
+ help: "3.8 Flash·High instead of Gemini 3.8 Flash (High).",
77
+ });
78
+ }
79
+ if (widget.id === "totalTokens") {
80
+ fields.push({
81
+ key: "split",
82
+ label: "Split",
83
+ values: [undefined, true],
84
+ show: (value) => onOff(value),
85
+ help: "Input and output tokens apart (123.1K↑ 355.0K↓) instead of one total.",
86
+ });
87
+ }
88
+ if (RESET_WIDGETS.has(widget.id)) {
89
+ fields.push({
90
+ key: "format",
91
+ label: "Format",
92
+ values: [undefined, "time", "both"],
93
+ show: (value) => value ?? "countdown",
94
+ help: "countdown (2hr 47m), time (6:33pm) or both (6:33pm (2hr 47m)).",
95
+ });
96
+ }
97
+ if (widget.id === "customText") {
98
+ fields.push({
99
+ key: "text",
100
+ label: "Text",
101
+ text: true,
102
+ show: (value) => value || "(empty: hidden)",
103
+ help: "The badge's text. Enter to type, Enter again to keep it, Esc to cancel.",
104
+ });
105
+ }
106
+ if (widget.id === "exec") {
107
+ const isFixed = !options.editableCmd;
108
+ fields.push({
109
+ key: "cmd",
110
+ label: "Command",
111
+ fixed: isFixed,
112
+ text: !isFixed,
113
+ show: (value) => value || "(none)",
114
+ help: options.cmdHelp
115
+ ? options.cmdHelp
116
+ : "The command runs only in agy, from your install folder.",
117
+ });
118
+ fields.push(
119
+ {
120
+ key: "payload",
121
+ label: "Session JSON",
122
+ values: [undefined, true],
123
+ show: (value) => onOff(value),
124
+ help: "Sends the session JSON to the command on stdin, without your email or session IDs.",
125
+ },
126
+ {
127
+ key: "colors",
128
+ label: "Keep colors",
129
+ values: [undefined, true],
130
+ show: (value) => onOff(value),
131
+ help: "Keeps the colors the command prints; other escape sequences are still removed.",
132
+ }
133
+ );
134
+ }
135
+ if (thresholds) {
136
+ const what = quota ? "left" : "used";
137
+ fields.push(
138
+ {
139
+ key: "warn",
140
+ label: "Caution at",
141
+ number: thresholds.warn,
142
+ show: (value) => (value === undefined ? `default (${thresholds.warn}%)` : `${value}%`),
143
+ help: `Turns to the caution color at this share ${what}.`,
144
+ },
145
+ {
146
+ key: "crit",
147
+ label: "Danger at",
148
+ number: thresholds.crit,
149
+ show: (value) => (value === undefined ? `default (${thresholds.crit}%)` : `${value}%`),
150
+ help: `Turns to the danger color at this share ${what}.`,
151
+ },
152
+ {
153
+ key: "blink",
154
+ label: "Blink",
155
+ values: [undefined, true, "warn", false],
156
+ show: (value) =>
157
+ value === undefined
158
+ ? `default (${thresholds.blink ? "on" : "off"})`
159
+ : { true: "at danger", warn: "at caution and danger", false: "off" }[String(value)],
160
+ help: "Makes the widget blink when it reaches the danger (or caution) level. ASCII mode never blinks.",
161
+ }
162
+ );
163
+ }
164
+ if (METER_WIDGETS.has(widget.id)) {
165
+ fields.push({
166
+ key: "meter",
167
+ label: "Meter",
168
+ values: [undefined, ...METER_STYLE_IDS],
169
+ show: (value) => value ?? "theme default",
170
+ help: "Meter style; ASCII mode always uses # and -",
171
+ });
172
+ }
173
+ if (PACED_WIDGETS.has(widget.id)) {
174
+ fields.push({
175
+ key: "pace",
176
+ label: "Pace marker",
177
+ values: [undefined, false, true],
178
+ show: (value) =>
179
+ value === undefined ? `default (${onOff(config.pacing !== false)})` : onOff(value),
180
+ help: "Marks where usage would be if it were spread evenly over the window. Default follows r.",
181
+ });
182
+ }
183
+ return fields;
184
+ }
185
+
186
+ module.exports = {
187
+ QUOTA_WIDGETS,
188
+ PACED_WIDGETS,
189
+ CONTEXT_WIDGETS,
190
+ METER_WIDGETS,
191
+ RESET_WIDGETS,
192
+ COLOR_SLOTS,
193
+ editFields,
194
+ };