agy-powerline 1.0.2 → 1.1.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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-10-12
9
+
10
+ ### Added
11
+
12
+ - `tasks` widget: running subagents and background tasks, e.g. `👥 2 subagents, 1 task`, hidden when nothing runs; part of the `full` preset (#14)
13
+ - `mode` widget: the approval mode (e.g. `accept-edits`) and the Vim mode, which Antigravity's own status line shows; hidden in the default mode with Vim editing off; part of the `workspace` and `full` presets (#15)
14
+ - `title` widget: the conversation's title on one line, cut to 30 columns, hidden until the conversation has one (#16)
15
+
16
+ ### Fixed
17
+
18
+ - The statusline no longer hangs until Antigravity kills it when Antigravity keeps its input open without sending anything; repeated kills made Antigravity turn the statusline off (#13)
19
+
8
20
  ## [1.0.2] - 2026-10-11
9
21
 
10
22
  ### Security
package/README.md CHANGED
@@ -42,7 +42,7 @@ Start from a preset, then adjust:
42
42
  | ----------- | ---------------------------------------------------------------------------- |
43
43
  | `default` | Model, context, git; 5h and 7d quotas with resets on a second row |
44
44
  | `compact` | One line: short model, tightest quota, context %, git |
45
- | `workspace` | Project, git, agent state, model; context and tightest quota |
45
+ | `workspace` | Project, git, agent state, model; context, tightest quota and mode |
46
46
  | `limits` | Every quota (5h, 7d, third-party), one per row |
47
47
  | `full` | Everything: session, project and git; context, tokens and cache; both quotas |
48
48
 
@@ -86,6 +86,9 @@ Preview them all with `npx agy-powerline theme --gallery`.
86
86
  | `git` | Branch, uncommitted changes and ahead/behind |
87
87
  | `project`, `cwd` | Project folder, current folder |
88
88
  | `agentState`, `plan`, `sandbox`, `artifacts`, `version` | What the agent is doing, your plan, sandbox, artifacts, CLI version |
89
+ | `tasks` | Running subagents and background tasks |
90
+ | `mode` | Approval mode (e.g. accept-edits) and Vim mode |
91
+ | `title` | The conversation's title |
89
92
  | `totalTokens`, `cacheHit`, `cacheRead`, `cacheCreate` | Session tokens and prompt cache usage |
90
93
  | `ram` | Memory used on your machine |
91
94
  | `customText`, `exec` | Your own text, or the output of a [shell command](docs/configuration.md#shell-command-widget-exec) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-powerline",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Interactive statusline for Google Antigravity",
5
5
  "bin": {
6
6
  "agy-powerline": "src/index.js"
@@ -105,6 +105,24 @@ const WIDGET_CATALOG = [
105
105
  unique: true,
106
106
  description: "number of artifacts",
107
107
  },
108
+ {
109
+ id: "tasks",
110
+ label: "Subagents & Tasks",
111
+ unique: true,
112
+ description: "running subagents and background tasks, shown only when there are some",
113
+ },
114
+ {
115
+ id: "mode",
116
+ label: "Mode",
117
+ unique: true,
118
+ description: "approval mode (e.g. accept-edits) and Vim mode, shown only when one is on",
119
+ },
120
+ {
121
+ id: "title",
122
+ label: "Conversation Title",
123
+ unique: true,
124
+ description: "name of the current conversation, shown once it has one",
125
+ },
108
126
  { id: "ram", label: "RAM Usage", unique: true, description: "memory used on this machine" },
109
127
  { id: "version", label: "CLI Version", unique: true, description: "Antigravity CLI version" },
110
128
  {
package/src/lib/config.js CHANGED
@@ -40,6 +40,9 @@ const VALID_WIDGET_IDS = new Set([
40
40
  "plan",
41
41
  "agentState",
42
42
  "artifacts",
43
+ "tasks",
44
+ "mode",
45
+ "title",
43
46
  "ram",
44
47
  "version",
45
48
  "sandbox",
@@ -260,7 +263,7 @@ const PRESETS = {
260
263
  workspace: {
261
264
  id: "workspace",
262
265
  name: "Several repos",
263
- description: "Project, git, agent state and model; context and tightest quota below",
266
+ description: "Project, git, agent state and model; context, tightest quota and mode below",
264
267
  widgets: [
265
268
  { id: "project" },
266
269
  { id: "git" },
@@ -271,6 +274,7 @@ const PRESETS = {
271
274
  { id: "context" },
272
275
  { id: "separator" },
273
276
  { id: "quota" },
277
+ { id: "mode" },
274
278
  ],
275
279
  },
276
280
  limits: {
@@ -294,6 +298,7 @@ const PRESETS = {
294
298
  description: "Session, project and git; context, tokens and cache; both quotas, on three rows",
295
299
  widgets: [
296
300
  { id: "agentState" },
301
+ { id: "tasks" },
297
302
  { id: "model" },
298
303
  { id: "plan" },
299
304
  { id: "sandbox" },
@@ -304,6 +309,7 @@ const PRESETS = {
304
309
  { id: "context" },
305
310
  { id: "totalTokens", split: true },
306
311
  { id: "cacheHit" },
312
+ { id: "mode" },
307
313
  { id: "lineBreak" },
308
314
  { id: "fiveHourBar" },
309
315
  { id: "fiveHourReset" },
package/src/lib/fit.js CHANGED
@@ -6,18 +6,21 @@ 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
+ "title",
9
10
  "version",
10
11
  "cacheCreate",
11
12
  "cacheRead",
12
13
  "totalTokens",
13
14
  "cacheHit",
14
15
  "artifacts",
16
+ "tasks",
15
17
  "plan",
16
18
  "ram",
17
19
  "project",
18
20
  "cwd",
19
21
  "exec",
20
22
  "agentState",
23
+ "mode",
21
24
  "thirdPartyWeekly",
22
25
  "thirdPartyFiveHour",
23
26
  "weeklyReset",
@@ -477,6 +477,37 @@ function formatGitSync(ahead, behind, ascii = false) {
477
477
  return "";
478
478
  }
479
479
 
480
+ // "2 subagents, 1 task", or "" when nothing runs.
481
+ function formatTasks(subagents, tasks) {
482
+ const part = (n, word) => (n > 0 ? `${n} ${word}${n === 1 ? "" : "s"}` : "");
483
+ return [part(subagents, "subagent"), part(tasks, "task")].filter(Boolean).join(", ");
484
+ }
485
+
486
+ // "accept-edits, NORMAL", or "" in the default mode with Vim editing off.
487
+ function formatMode(approval, vim) {
488
+ return [approval === "default" ? "" : approval, vim].filter(Boolean).join(", ");
489
+ }
490
+
491
+ // Cuts text to `max` terminal columns, ending with an ellipsis when cut.
492
+ function clipText(text, max, ascii = false) {
493
+ const value = String(text);
494
+ if (visibleWidth(value) <= max) {
495
+ return value;
496
+ }
497
+ const ellipsis = ascii ? "..." : "…";
498
+ let out = "";
499
+ let width = 0;
500
+ for (const char of value) {
501
+ const w = charWidth(char.codePointAt(0));
502
+ if (width + w > max - ellipsis.length) {
503
+ break;
504
+ }
505
+ out += char;
506
+ width += w;
507
+ }
508
+ return out.trimEnd() + ellipsis;
509
+ }
510
+
480
511
  // Removes escape sequences and control characters from text that did not
481
512
  // come from us (payload fields, branch names, command output), so it cannot
482
513
  // drive the terminal: cursor moves, OSC 52 clipboard writes, hyperlinks...
@@ -555,6 +586,9 @@ module.exports = {
555
586
  shortModelName,
556
587
  formatTokenSplit,
557
588
  formatGitSync,
589
+ formatTasks,
590
+ formatMode,
591
+ clipText,
558
592
  quotaColor,
559
593
  contextColor,
560
594
  resolveQuotaThresholds,
@@ -8,6 +8,9 @@ const {
8
8
  paceIndex,
9
9
  paceGlyph,
10
10
  formatGitSync,
11
+ formatTasks,
12
+ formatMode,
13
+ clipText,
11
14
  formatTokens,
12
15
  formatContextTokens,
13
16
  shortModelName,
@@ -28,6 +31,7 @@ const {
28
31
  hasNoData,
29
32
  RAM_CAUTION,
30
33
  RAM_DANGER,
34
+ TITLE_MAX,
31
35
  } = require("../widget-style");
32
36
 
33
37
  const LINES = NAMED_COLORS;
@@ -374,6 +378,31 @@ function buildLinesSegment(id, session, palette, emojis, ascii = false, widget =
374
378
  }
375
379
  return fg(palette.text) + tag() + String(session.artifacts) + RESET;
376
380
 
381
+ case "tasks": {
382
+ const text = formatTasks(session.subagents, session.tasks);
383
+ if (!text) {
384
+ return null;
385
+ }
386
+ return fg(palette.text) + tag() + text + RESET;
387
+ }
388
+
389
+ case "mode": {
390
+ const text = formatMode(session.approvalMode, session.vimMode);
391
+ if (!text) {
392
+ return null;
393
+ }
394
+ // An approval mode like accept-edits changes what the agent may do alone.
395
+ const approval = session.approvalMode && session.approvalMode !== "default";
396
+ const color = approval ? palette.quotaHigh || palette.yellow || LINES.yellow : palette.text;
397
+ return fg(color) + tag() + text + RESET;
398
+ }
399
+
400
+ case "title":
401
+ if (!session.title) {
402
+ return null;
403
+ }
404
+ return fg(palette.text) + tag() + clipText(session.title, TITLE_MAX, ascii) + RESET;
405
+
377
406
  case "sandbox": {
378
407
  if (!session.sandbox) {
379
408
  return null;
@@ -24,6 +24,9 @@ const {
24
24
  shortModelName,
25
25
  formatTokenSplit,
26
26
  formatGitSync,
27
+ formatTasks,
28
+ formatMode,
29
+ clipText,
27
30
  homePath,
28
31
  basenameOf,
29
32
  label,
@@ -41,6 +44,7 @@ const {
41
44
  hasNoData,
42
45
  RAM_CAUTION,
43
46
  RAM_DANGER,
47
+ TITLE_MAX,
44
48
  } = require("../widget-style");
45
49
 
46
50
  const WHITE = [255, 255, 255];
@@ -420,6 +424,30 @@ function buildSegment(id, session, colors, emojis, ascii = false, widget = {}, c
420
424
  }
421
425
  return pill(colors.meta, lbl(String(session.artifacts)));
422
426
 
427
+ case "tasks": {
428
+ const text = formatTasks(session.subagents, session.tasks);
429
+ if (!text) {
430
+ return null;
431
+ }
432
+ return pill(colors.meta, lbl(text));
433
+ }
434
+
435
+ case "mode": {
436
+ const text = formatMode(session.approvalMode, session.vimMode);
437
+ if (!text) {
438
+ return null;
439
+ }
440
+ // An approval mode like accept-edits changes what the agent may do alone.
441
+ const approval = session.approvalMode && session.approvalMode !== "default";
442
+ return pill(approval ? colors.caution : colors.meta, lbl(text));
443
+ }
444
+
445
+ case "title":
446
+ if (!session.title) {
447
+ return null;
448
+ }
449
+ return pill(colors.meta, lbl(clipText(session.title, TITLE_MAX, ascii)));
450
+
423
451
  case "ram": {
424
452
  const pct = session.ramPct ?? 0;
425
453
  let color = colors.meta;
@@ -62,6 +62,13 @@ function sampleData() {
62
62
  },
63
63
  sandbox: { enabled: false },
64
64
  artifact_count: 2,
65
+ subagents: [
66
+ { name: "research", role: "Find where the config is loaded", status: "running" },
67
+ { name: "review", role: "Check the error messages", status: "completed" },
68
+ ],
69
+ task_count: 1,
70
+ cycle_mode: "accept-edits",
71
+ conversation_title: "Fix the config loader",
65
72
  plan_tier: "Pro",
66
73
  terminal_width: process.stdout.columns || 120,
67
74
  };
@@ -17,6 +17,7 @@ const TEXT = "text";
17
17
  const TIME = "time";
18
18
  const PATH = "path";
19
19
  const BRANCH = "branch";
20
+ const TITLE = "title";
20
21
 
21
22
  const QUOTA = {
22
23
  remaining_fraction: NUMBER,
@@ -52,6 +53,11 @@ const SCHEMA = {
52
53
  vcs: VCS,
53
54
  sandbox: { enabled: BOOL, allow_network: BOOL },
54
55
  artifact_count: NUMBER,
56
+ subagents: "subagents",
57
+ task_count: NUMBER,
58
+ cycle_mode: TEXT,
59
+ vim: { mode: TEXT },
60
+ conversation_title: TITLE,
55
61
  terminal_width: NUMBER,
56
62
  };
57
63
 
@@ -102,6 +108,18 @@ function pick(value, rule, seen) {
102
108
  if (rule === BRANCH) {
103
109
  return typeof value === "string" && value ? branchPlaceholder(value) : undefined;
104
110
  }
111
+ if (rule === TITLE) {
112
+ // A title can name the user's work; the placeholder keeps its length.
113
+ return typeof value === "string" && value
114
+ ? "title" + "x".repeat(Math.max(0, Math.min(200, value.length) - 5))
115
+ : undefined;
116
+ }
117
+ if (rule === "subagents") {
118
+ // Only the status: a subagent's name and role describe the user's work.
119
+ return Array.isArray(value)
120
+ ? value.slice(0, 50).map((agent) => pick(agent, { status: TEXT }, seen) || {})
121
+ : undefined;
122
+ }
105
123
  if (rule === "quota") {
106
124
  if (typeof value !== "object") {
107
125
  return undefined;
@@ -175,6 +175,16 @@ function extractSession(data, config) {
175
175
  plan: firstText(data.plan_tier),
176
176
  agentState: firstText(data.agent_state),
177
177
  artifacts: Math.floor(count(data.artifact_count)),
178
+ // Finished subagents stay in the list Antigravity sends; only running ones count.
179
+ subagents: Array.isArray(data.subagents)
180
+ ? data.subagents.filter((agent) => agent?.status === "running").length
181
+ : 0,
182
+ tasks: Math.floor(count(data.task_count)),
183
+ // Left out in the default mode, and with Vim editing off.
184
+ approvalMode: firstText(data.cycle_mode),
185
+ vimMode: firstText(data.vim?.mode),
186
+ // Left out until the conversation has a title; kept to one line.
187
+ title: firstText(data.conversation_title).replace(/\s+/g, " ").trim(),
178
188
  version: firstText(data.version),
179
189
  sandbox: data.sandbox?.enabled === true,
180
190
  sandboxNet: data.sandbox?.allow_network === true,
@@ -102,6 +102,9 @@ const WIDGET_TAGS = {
102
102
  plan: { icon: "⭐", name: "Plan" },
103
103
  agentState: { icon: "🟣", name: "Agent" },
104
104
  artifacts: { icon: "📎", name: "Artifacts" },
105
+ tasks: { icon: "👥", name: "" },
106
+ mode: { icon: "🧭", name: "" },
107
+ title: { icon: "💬", name: "" },
105
108
  ram: { icon: "💻", name: "RAM" },
106
109
  version: { icon: "🔖", name: "" },
107
110
  sandbox: { icon: "🔒", name: "" },
@@ -127,6 +130,9 @@ function widgetTag(id, { emojis = true, raw = false, icon } = {}) {
127
130
  return `${icon ?? tag.icon} ${tag.iconOnly ? "" : named}`;
128
131
  }
129
132
 
133
+ // Longest conversation title shown, in terminal columns.
134
+ const TITLE_MAX = 30;
135
+
130
136
  // RAM use (%) that turns the ram widget to the caution, then danger color.
131
137
  const RAM_CAUTION = 80;
132
138
  const RAM_DANGER = 90;
@@ -154,4 +160,5 @@ module.exports = {
154
160
  hasNoData,
155
161
  RAM_CAUTION,
156
162
  RAM_DANGER,
163
+ TITLE_MAX,
157
164
  };
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- const fs = require("fs");
3
2
  const path = require("path");
4
3
  const { readConfigFile, renderStatusline } = require("./shared");
5
4
  const { isTrustedInstallDir } = require("./paths");
@@ -17,6 +16,12 @@ const {
17
16
  // A refresh slower than this is worth a line in the problem log.
18
17
  const SLOW_RENDER_MS = 750;
19
18
 
19
+ // Antigravity can start the command and keep stdin open without writing to it
20
+ // (google-antigravity/antigravity-cli#698). A blocking read would wait until
21
+ // Antigravity kills the script, and a few kills in a row turn the custom
22
+ // statusline off, so the read gives up after this long.
23
+ const STDIN_TIMEOUT_MS = 1000;
24
+
20
25
  // The config decides whether problems are logged, so it is read first. A
21
26
  // broken config can't say, so logging stays on (AGY_POWERLINE_LOG=0 still
22
27
  // turns it off).
@@ -32,58 +37,87 @@ process.on("uncaughtException", (err) => {
32
37
  process.exit(0);
33
38
  });
34
39
 
35
- const input = fs
36
- .readFileSync(0, "utf8")
37
- .replace(/^\uFEFF/, "")
38
- .trim();
39
-
40
- if (!input) {
41
- process.exit(0);
40
+ // Resolves with what stdin sent and whether it closed before the deadline.
41
+ function readStdin(timeoutMs) {
42
+ return new Promise((resolve) => {
43
+ const chunks = [];
44
+ let done = false;
45
+ const finish = (complete) => {
46
+ if (done) return;
47
+ done = true;
48
+ clearTimeout(timer);
49
+ // An open stdin would keep the process alive after the bar is printed.
50
+ process.stdin.destroy();
51
+ resolve({ text: Buffer.concat(chunks).toString("utf8"), complete });
52
+ };
53
+ const timer = setTimeout(() => finish(false), timeoutMs);
54
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
55
+ process.stdin.on("end", () => finish(true));
56
+ process.stdin.on("error", () => finish(true));
57
+ });
42
58
  }
43
59
 
44
- // Debug dumps keep only the fields the bar renders, never personal ones.
45
- const dumpPath =
46
- dumpTarget(process.env.AGY_POWERLINE_DUMP_PAYLOAD, __dirname) ||
47
- (process.env.AGY_POWERLINE_DEBUG ? path.join(__dirname, "last-payload.json") : null);
48
- if (dumpPath) {
49
- writePayloadDump(dumpPath, input);
50
- }
60
+ readStdin(STDIN_TIMEOUT_MS).then(({ text, complete }) => {
61
+ render(text.replace(/^\uFEFF/, "").trim(), complete);
62
+ });
51
63
 
52
- let data;
64
+ function render(input, complete) {
65
+ if (!input) {
66
+ if (!complete) {
67
+ logWarning("STDIN_TIMEOUT", { ms: STDIN_TIMEOUT_MS });
68
+ }
69
+ process.exit(0);
70
+ }
53
71
 
54
- try {
55
- data = JSON.parse(input);
56
- } catch (err) {
57
- logWarning("PAYLOAD_INVALID_JSON", { message: jsonErrorMessage(err.message) });
58
- // Node's message quotes the bad input; only say where it broke.
59
- process.stderr.write(`[agy-powerline] ${jsonErrorMessage(err.message)} on stdin\n`);
60
- process.stdout.write("\x1b[31m[agy-powerline: invalid JSON]\x1b[0m\n");
61
- process.exit(0);
62
- }
72
+ // Debug dumps keep only the fields the bar renders, never personal ones.
73
+ const dumpPath =
74
+ dumpTarget(process.env.AGY_POWERLINE_DUMP_PAYLOAD, __dirname) ||
75
+ (process.env.AGY_POWERLINE_DEBUG ? path.join(__dirname, "last-payload.json") : null);
76
+ if (dumpPath) {
77
+ writePayloadDump(dumpPath, input);
78
+ }
63
79
 
64
- // The one payload value the log keeps: Antigravity's version number.
65
- if (typeof data?.version === "string" && /^\d[\w.+-]{0,30}$/.test(data.version)) {
66
- setLogContext({ antigravity: data.version });
67
- }
80
+ let data;
68
81
 
69
- // The config lives next to this script. A copy anywhere other than the
70
- // global install or AGY_POWERLINE_HOME (e.g. committed inside a repo) may be
71
- // edited by someone else, so its exec widgets stay off.
72
- const exec = isTrustedInstallDir(__dirname) ? "run" : "blocked";
82
+ try {
83
+ data = JSON.parse(input);
84
+ } catch (err) {
85
+ // Stdin was cut off by the deadline: nothing worth showing.
86
+ if (!complete) {
87
+ logWarning("STDIN_TIMEOUT", { ms: STDIN_TIMEOUT_MS });
88
+ process.exit(0);
89
+ }
90
+ logWarning("PAYLOAD_INVALID_JSON", { message: jsonErrorMessage(err.message) });
91
+ // Node's message quotes the bad input; only say where it broke.
92
+ process.stderr.write(`[agy-powerline] ${jsonErrorMessage(err.message)} on stdin\n`);
93
+ process.stdout.write("\x1b[31m[agy-powerline: invalid JSON]\x1b[0m\n");
94
+ process.exit(0);
95
+ }
73
96
 
74
- if (configError) {
75
- logWarning("CONFIG_INVALID", {
76
- message: /JSON/.test(configError) ? jsonErrorMessage(configError) : redact(configError),
77
- });
78
- process.stderr.write(`[agy-powerline] invalid statusline.config.json: ${configError}\n`);
79
- }
97
+ // The one payload value the log keeps: Antigravity's version number.
98
+ if (typeof data?.version === "string" && /^\d[\w.+-]{0,30}$/.test(data.version)) {
99
+ setLogContext({ antigravity: data.version });
100
+ }
80
101
 
81
- const started = Date.now();
82
- const output = renderStatusline(data, config, { exec, configError });
83
- const elapsed = Date.now() - started;
102
+ // The config lives next to this script. A copy anywhere other than the
103
+ // global install or AGY_POWERLINE_HOME (e.g. committed inside a repo) may be
104
+ // edited by someone else, so its exec widgets stay off.
105
+ const exec = isTrustedInstallDir(__dirname) ? "run" : "blocked";
84
106
 
85
- if (elapsed > SLOW_RENDER_MS) {
86
- logWarning("SLOW_RENDER", { ms: elapsed, widgets: config.widgets.map((widget) => widget.id) });
87
- }
107
+ if (configError) {
108
+ logWarning("CONFIG_INVALID", {
109
+ message: /JSON/.test(configError) ? jsonErrorMessage(configError) : redact(configError),
110
+ });
111
+ process.stderr.write(`[agy-powerline] invalid statusline.config.json: ${configError}\n`);
112
+ }
88
113
 
89
- process.stdout.write(output);
114
+ const started = Date.now();
115
+ const output = renderStatusline(data, config, { exec, configError });
116
+ const elapsed = Date.now() - started;
117
+
118
+ if (elapsed > SLOW_RENDER_MS) {
119
+ logWarning("SLOW_RENDER", { ms: elapsed, widgets: config.widgets.map((widget) => widget.id) });
120
+ }
121
+
122
+ process.stdout.write(output);
123
+ }