agy-powerline 1.0.2 → 1.1.1

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,25 @@ 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.1] - 2026-10-12
9
+
10
+ ### Changed
11
+
12
+ - The statusline is installed as a single `statusline.js` instead of about 16 files, which cuts agy-powerline's own share of each refresh by about a third on Windows; run `npx agy-powerline init --yes` to switch, and `init` removes the old files (#23)
13
+ - The ahead/behind counts are kept per pair of commits, so a branch that is ahead of or behind its upstream no longer starts `git` on every refresh (about 60 ms each on Windows) (#24)
14
+
15
+ ## [1.1.0] - 2026-10-12
16
+
17
+ ### Added
18
+
19
+ - `tasks` widget: running subagents and background tasks, e.g. `👥 2 subagents, 1 task`, hidden when nothing runs; part of the `full` preset (#14)
20
+ - `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)
21
+ - `title` widget: the conversation's title on one line, cut to 30 columns, hidden until the conversation has one (#16)
22
+
23
+ ### Fixed
24
+
25
+ - 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)
26
+
8
27
  ## [1.0.2] - 2026-10-11
9
28
 
10
29
  ### 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.1",
4
4
  "description": "Interactive statusline for Google Antigravity",
5
5
  "bin": {
6
6
  "agy-powerline": "src/index.js"
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+
3
+ // Builds the installed statusline.js: the runtime script and the library
4
+ // files in RUNTIME_FILES, joined into one file. Node loads a single file much
5
+ // faster than ~16 small ones (on Windows, opening each file costs more than
6
+ // running its code), and Antigravity starts the script on every refresh.
7
+ //
8
+ // Each file keeps its own scope: its source is wrapped in a function that
9
+ // gets its own module, exports and require, the way Node wraps a file. No
10
+ // eval, no code generation beyond that wrapper. LINES records where each file
11
+ // starts, so the problem log can still name the original file and line.
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { RUNTIME_FILES } = require("../lib/paths");
16
+
17
+ const LIB_DIR = path.join(__dirname, "..", "lib");
18
+ const ENTRY = path.join(__dirname, "..", "runtime", "statusline.js");
19
+
20
+ // Requires between bundled files resolve inside the bundle; anything else
21
+ // (Node built-ins) goes to Node's own require.
22
+ const PRELUDE = `const __files = {};
23
+ const __cache = {};
24
+ function __require(from, id) {
25
+ if (!id.startsWith(".")) {
26
+ return require(id);
27
+ }
28
+ let key = require("path").posix.join(require("path").posix.dirname(from), id);
29
+ if (!key.endsWith(".js")) {
30
+ key += ".js";
31
+ }
32
+ if (!__files[key]) {
33
+ const err = new Error(\`Cannot find module '\${id}' from '\${from}'\`);
34
+ err.code = "MODULE_NOT_FOUND";
35
+ throw err;
36
+ }
37
+ if (!__cache[key]) {
38
+ const module = { exports: {} };
39
+ __cache[key] = module;
40
+ __files[key](module, module.exports, (next) => __require(key, next));
41
+ }
42
+ return __cache[key].exports;
43
+ }
44
+ `;
45
+
46
+ function toKey(rel) {
47
+ return rel.split(path.sep).join("/");
48
+ }
49
+
50
+ function wrapperLine(key) {
51
+ return `__files[${JSON.stringify(key)}] = function (module, exports, require) {`;
52
+ }
53
+
54
+ function buildRuntimeBundle() {
55
+ const files = [
56
+ ...RUNTIME_FILES.map((rel) => [toKey(rel), path.join(LIB_DIR, rel)]),
57
+ ["statusline.js", ENTRY],
58
+ ];
59
+ const parts = ["#!/usr/bin/env node", PRELUDE];
60
+
61
+ for (const [key, file] of files) {
62
+ const body = fs
63
+ .readFileSync(file, "utf8")
64
+ .replace(/^#!.*\n/, "")
65
+ .replace(/\n+$/, "");
66
+ parts.push(wrapperLine(key), body, "};");
67
+ }
68
+
69
+ // [first line of the file in the bundle, file name]; 1-based like stack traces.
70
+ const text = parts.join("\n");
71
+ const bundleLines = text.split("\n");
72
+ const lines = files.map(([key]) => [bundleLines.indexOf(wrapperLine(key)) + 2, key]);
73
+
74
+ return [
75
+ text,
76
+ `__require("statusline.js", "./log").setBundleLines(${JSON.stringify(lines)});`,
77
+ `__require("statusline.js", "./statusline");`,
78
+ "",
79
+ ].join("\n");
80
+ }
81
+
82
+ module.exports = { buildRuntimeBundle };
package/src/cli/init.js CHANGED
@@ -18,9 +18,8 @@ const {
18
18
  cmdBreakingMessage,
19
19
  } = require("../lib/paths");
20
20
  const { createRl, confirm } = require("../lib/prompt");
21
+ const { buildRuntimeBundle } = require("./bundle");
21
22
 
22
- const RUNTIME_DIR = path.join(__dirname, "..", "runtime");
23
- const LIB_DIR = path.join(__dirname, "..", "lib");
24
23
  const DIM = "\x1b[2m";
25
24
  const BOLD = "\x1b[1m";
26
25
  const CYAN = "\x1b[36m";
@@ -29,6 +28,27 @@ const YELLOW = "\x1b[33m";
29
28
  const RED = "\x1b[31m";
30
29
  const RESET = "\x1b[0m";
31
30
 
31
+ // Up to 1.1.0 the runtime was installed as separate files next to
32
+ // statusline.js. They are part of the one-file statusline.js now; delete the
33
+ // old copies by name (regular files only), then the emptied layouts folder.
34
+ function removeSeparateRuntimeFiles(installDir) {
35
+ for (const rel of RUNTIME_FILES) {
36
+ const file = path.join(installDir, rel);
37
+ try {
38
+ if (fs.lstatSync(file).isFile()) {
39
+ fs.unlinkSync(file);
40
+ }
41
+ } catch {
42
+ // Not there: nothing to remove.
43
+ }
44
+ }
45
+ try {
46
+ fs.rmdirSync(path.join(installDir, "layouts"));
47
+ } catch {
48
+ // Missing, or holds something that isn't ours: leave it.
49
+ }
50
+ }
51
+
32
52
  function assertUnixNode() {
33
53
  if (!isWindowsNodeOnWsl()) {
34
54
  return;
@@ -104,13 +124,8 @@ async function initCommand(options = {}) {
104
124
  }
105
125
 
106
126
  if (writeScripts) {
107
- fs.copyFileSync(path.join(RUNTIME_DIR, "statusline.js"), destStatusline);
108
- for (const rel of RUNTIME_FILES) {
109
- const src = path.join(LIB_DIR, rel);
110
- const dest = path.join(installDir, rel);
111
- fs.mkdirSync(path.dirname(dest), { recursive: true });
112
- fs.copyFileSync(src, dest);
113
- }
127
+ fs.writeFileSync(destStatusline, buildRuntimeBundle(), "utf8");
128
+ removeSeparateRuntimeFiles(installDir);
114
129
 
115
130
  if (process.platform !== "win32") {
116
131
  try {
@@ -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,
package/src/lib/git.js CHANGED
@@ -4,6 +4,14 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const { logWarning } = require("./log");
6
6
  const { firstText } = require("./formatters");
7
+ const { writeFileAtomic, PRIVATE_FILE_MODE } = require("./paths");
8
+
9
+ // Ahead/behind counts depend only on the two commits compared, so they are
10
+ // kept per commit pair: git runs again only when the branch or its upstream
11
+ // moves. Starting git costs ~60 ms on Windows, on every refresh otherwise.
12
+ const SYNC_CACHE_NAME = ".git-sync-cache.json";
13
+ const SYNC_CACHE_MAX = 20;
14
+ const COMMIT = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
7
15
 
8
16
  // Only an absolute folder that exists can belong to the user's session. A
9
17
  // relative path (a Windows path read on Linux, for one) resolves against
@@ -217,6 +225,54 @@ function upstreamRef(commonDir, branch) {
217
225
  return remote === "." ? merge : `refs/remotes/${remote}/${tracked}`;
218
226
  }
219
227
 
228
+ // Only next to a real install's config (like the exec cache); CLI previews
229
+ // and other copies don't write it.
230
+ function syncCachePath() {
231
+ try {
232
+ const { getConfigDir } = require("./config");
233
+ const dir = getConfigDir();
234
+ return dir && fs.existsSync(path.join(dir, "statusline.config.json"))
235
+ ? path.join(dir, SYNC_CACHE_NAME)
236
+ : null;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ function readSyncCache(file) {
243
+ try {
244
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
245
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
246
+ } catch {
247
+ return {};
248
+ }
249
+ }
250
+
251
+ function cachedSync(file, key) {
252
+ const value = file ? readSyncCache(file)[key] : null;
253
+ const valid =
254
+ Array.isArray(value) &&
255
+ value.length === 2 &&
256
+ value.every((n) => Number.isInteger(n) && n >= 0 && n <= 1e7);
257
+ return valid ? { ahead: value[0], behind: value[1] } : null;
258
+ }
259
+
260
+ function saveSync(file, key, sync) {
261
+ if (!file) {
262
+ return;
263
+ }
264
+ try {
265
+ // Newest last; the oldest pairs go once there are more than SYNC_CACHE_MAX.
266
+ const cache = readSyncCache(file);
267
+ delete cache[key];
268
+ cache[key] = [sync.ahead, sync.behind];
269
+ const kept = Object.fromEntries(Object.entries(cache).slice(-SYNC_CACHE_MAX));
270
+ writeFileAtomic(file, JSON.stringify(kept), { mode: PRIVATE_FILE_MODE });
271
+ } catch {
272
+ // Best effort: the next refresh runs git again.
273
+ }
274
+ }
275
+
220
276
  function getGitSync(gitDir, branch) {
221
277
  if (!gitDir || !isSafeBranchName(branch)) {
222
278
  return { ahead: 0, behind: 0 };
@@ -232,10 +288,19 @@ function getGitSync(gitDir, branch) {
232
288
  return { ahead: 0, behind: 0 };
233
289
  }
234
290
 
235
- if (readRef(commonDir, upstream) === localHash) {
291
+ const upstreamHash = readRef(commonDir, upstream);
292
+ if (upstreamHash === localHash) {
236
293
  return { ahead: 0, behind: 0 };
237
294
  }
238
295
 
296
+ const key =
297
+ COMMIT.test(localHash) && COMMIT.test(upstreamHash) ? `${localHash}..${upstreamHash}` : "";
298
+ const cacheFile = key ? syncCachePath() : null;
299
+ const cached = cachedSync(cacheFile, key);
300
+ if (cached) {
301
+ return cached;
302
+ }
303
+
239
304
  const { execFileSync } = require("child_process");
240
305
  const out = execFileSync(
241
306
  "git",
@@ -253,10 +318,12 @@ function getGitSync(gitDir, branch) {
253
318
 
254
319
  const parts = out.split(/\s+/);
255
320
  if (parts.length >= 2) {
256
- return {
321
+ const sync = {
257
322
  ahead: parseInt(parts[0], 10) || 0,
258
323
  behind: parseInt(parts[1], 10) || 0,
259
324
  };
325
+ saveSync(cacheFile, key, sync);
326
+ return sync;
260
327
  }
261
328
  } catch (err) {
262
329
  // A timeout or missing git is worth knowing about when ahead/behind
@@ -327,4 +394,5 @@ module.exports = {
327
394
  gitHasDirtyMarkers,
328
395
  vcsFromPayload,
329
396
  resolveGit,
397
+ SYNC_CACHE_NAME,
330
398
  };
@@ -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;
package/src/lib/log.js CHANGED
@@ -28,6 +28,28 @@ const IN_REPO = path.basename(__dirname) === "lib";
28
28
  let logDir = null;
29
29
  let context = {};
30
30
 
31
+ // The installed statusline.js is the runtime files joined into one (see
32
+ // src/cli/bundle.js). [first line, file name] for each of them, so a stack
33
+ // frame can still name the original file and line.
34
+ let bundleLines = null;
35
+
36
+ function setBundleLines(lines) {
37
+ bundleLines = Array.isArray(lines) ? lines : null;
38
+ }
39
+
40
+ function originalFrame(rel, line) {
41
+ if (!bundleLines || rel !== "statusline.js") {
42
+ return `${rel}:${line}`;
43
+ }
44
+ let match = null;
45
+ for (const entry of bundleLines) {
46
+ if (entry[0] <= line) {
47
+ match = entry;
48
+ }
49
+ }
50
+ return match ? `${match[1]}:${line - match[0] + 1}` : `${rel}:${line}`;
51
+ }
52
+
31
53
  // The copied runtime has no package.json; init records the version in
32
54
  // install.json instead.
33
55
  function agyPowerlineVersion() {
@@ -139,7 +161,7 @@ function ourFrames(stack) {
139
161
  continue;
140
162
  }
141
163
  const fn = match[1] && /^[\w$.<> ]{1,60}$/.test(match[1]) ? `${match[1]} ` : "";
142
- frames.push(`${fn}${rel.split(path.sep).join("/")}:${match[3]}`);
164
+ frames.push(`${fn}${originalFrame(rel.split(path.sep).join("/"), Number(match[3]))}`);
143
165
  if (frames.length >= 8) {
144
166
  break;
145
167
  }
@@ -283,4 +305,5 @@ module.exports = {
283
305
  logError,
284
306
  logWarning,
285
307
  clearLog,
308
+ setBundleLines,
286
309
  };
@@ -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
  };
package/src/lib/paths.js CHANGED
@@ -8,7 +8,8 @@ const AGY_SETTINGS = path.join(os.homedir(), ".gemini", "antigravity-cli", "sett
8
8
 
9
9
  const INSTALL_META_NAME = "install.json";
10
10
 
11
- // Library files init copies next to statusline.js (paths relative to src/lib).
11
+ // Library files the runtime needs (paths relative to src/lib). init joins them
12
+ // with src/runtime/statusline.js into the one installed statusline.js.
12
13
  const RUNTIME_FILES = [
13
14
  "shared.js",
14
15
  "colors.js",
@@ -35,13 +36,16 @@ const PRIVATE_FILES = [
35
36
  "statusline.config.json",
36
37
  ".exec-cache.json",
37
38
  "last-payload.json",
39
+ // SYNC_CACHE_NAME in git.js: commit IDs of the repos you work in.
40
+ ".git-sync-cache.json",
38
41
  // LOG_NAME in log.js, spelled out because log.js requires this file.
39
42
  "agy-powerline.log",
40
43
  ];
41
44
  const PRIVATE_FILE_MODE = 0o600;
42
45
 
43
46
  // Everything init or the runtime may create in the install dir. uninstall
44
- // --delete removes only these, never the whole directory tree.
47
+ // --delete removes only these, never the whole directory tree. RUNTIME_FILES
48
+ // are listed for installs from 1.1.0 and earlier, which copied them separately.
45
49
  const INSTALLED_FILES = [
46
50
  "statusline.js",
47
51
  "statusline.cmd",
@@ -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
+ }