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.
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,