@astrofoundry/pi-astro 0.12.1 → 0.13.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/README.md CHANGED
@@ -65,7 +65,7 @@ pi # launch; confirm [Extensions] lists astro-agents, grimoire, a
65
65
  - `notify-on-stop` — runs a shell command when the agent finishes a turn (sound, voice, desktop notification). **Default: off.** Enable with `/notify on` (state persists in `~/.pi/agent/notify-on-stop.json`); disable with `/notify off`. macOS default command: plays the Glass system sound **and** speaks "Agent done" via `say` using the **`Samantha (Enhanced)`** voice. Linux default: `notify-send "pi" "Agent done"`. Override the voice with `PI_STOP_NOTIFY_VOICE=<voice-name>` (macOS only; e.g. `Alex`, `Karen`, `Daniel (Enhanced)`). Replace the full command with `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`. Hard-kill (overrides `/notify on`) with `PI_STOP_NOTIFY_OFF=1`. Commands: `/notify [on|off|status|test]`. See [extensions/notify-on-stop/README.md](extensions/notify-on-stop/README.md) for full details.
66
66
  - **macOS voice install (required once for the default):** open **System Settings → Accessibility → Spoken Content → System Voice → Manage Voices…**, expand **English**, check **Samantha (Enhanced)**, click **Done** to download (~500 MB – 1 GB). Verify with `say -v "Samantha (Enhanced)" hi`. If the voice is missing, `say` errors silently and you'll only hear the Glass sound.
67
67
  - `vscode-image` — only active inside VS Code's integrated terminal (`TERM_PROGRAM=vscode`). Switches pi-tui's image output to the **Kitty graphics protocol** so images returned by tools like `gemini_image` render as real pixels instead of the `[Image: …]` text fallback. **Requires enabling `Terminal › Integrated: Enable Images` in VS Code settings** (off by default); restart the integrated terminal after flipping it. Disable with `PI_VSCODE_IMAGE_OFF=1`. `/vscode-image` prints current status.
68
- - `astro-footer` — single-line powerline-style footer: pi marker, model, thinking level, cwd basename, git branch, total tokens, cost, context %, plus any extension statuses (`ctx.ui.setStatus`). Auto-detects Nerd Fonts (iTerm/WezTerm/Kitty/Ghostty/Alacritty) with ASCII fallback. Override via `ASTRO_FOOTER_NERD_FONTS=0|1`. Toggle with `/footer [on|off|status]`. See [extensions/astro-footer/README.md](extensions/astro-footer/README.md).
68
+ - `astro-footer` — two-line powerline-style footer (row 2 only when there's info to show). Row 1: pi · pretty-printed model · thinking · path · git branch + `±` counts · tokens (in/out split) · cost (or `(sub)` for OAuth-authed plans) · context %/total + auto-compact `AC`. Row 2: subagent count · session time · session name · cache read/write · extension statuses. Auto-detects Nerd Fonts (iTerm/WezTerm/Kitty/Ghostty/Alacritty) with ASCII fallback. Env overrides: `ASTRO_FOOTER_NERD_FONTS=0|1`, `ASTRO_FOOTER_PATH=basename|abbreviated|full`. Toggle with `/footer [on|off|status]`. See [extensions/astro-footer/README.md](extensions/astro-footer/README.md).
69
69
  - `astro-snake` — `/snake` launches an in-pi Snake game (Astro-themed, theme-aware colours, speed ramp, no persistence). See [extensions/astro-snake/README.md](extensions/astro-snake/README.md).
70
70
  - `caveman` — `/caveman [lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off|status]` toggles a persistent compressed-output mode. No argument toggles between off and the default level (`full`). Active level is shown as a footer badge and survives `/reload`. The skill body at `skills/caveman/SKILL.md` is also available as a one-shot via `/skill:caveman`.
71
71
 
@@ -27,6 +27,7 @@ type ToolDef = {
27
27
  ui: {
28
28
  notify: ReturnType<typeof vi.fn>;
29
29
  confirm: ReturnType<typeof vi.fn>;
30
+ setStatus: ReturnType<typeof vi.fn>;
30
31
  };
31
32
  },
32
33
  ) => Promise<{ content: Array<{ text: string }>; details: { agent: string; source: string } }>;
@@ -40,7 +41,11 @@ function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void } {
40
41
  function makeCtx(): {
41
42
  cwd: string;
42
43
  hasUI: boolean;
43
- ui: { notify: ReturnType<typeof vi.fn>; confirm: ReturnType<typeof vi.fn> };
44
+ ui: {
45
+ notify: ReturnType<typeof vi.fn>;
46
+ confirm: ReturnType<typeof vi.fn>;
47
+ setStatus: ReturnType<typeof vi.fn>;
48
+ };
44
49
  } {
45
50
  return {
46
51
  cwd: "/tmp/test-cwd",
@@ -48,6 +53,7 @@ function makeCtx(): {
48
53
  ui: {
49
54
  notify: vi.fn(),
50
55
  confirm: vi.fn().mockResolvedValue(true),
56
+ setStatus: vi.fn(),
51
57
  },
52
58
  };
53
59
  }
@@ -66,7 +66,15 @@ function agentBadge(agentName: string, color: string | undefined): string {
66
66
  return `${bg}${ANSI_BOLD} ${agentName} ${ANSI_RESET}`;
67
67
  }
68
68
 
69
+ const SUBAGENT_STATUS_KEY = "subagents";
70
+
69
71
  export default function astroAgentsExtension(pi: ExtensionAPI): void {
72
+ let activeAgents = 0;
73
+
74
+ function publishCount(ctx: ExtensionContext): void {
75
+ ctx.ui.setStatus(SUBAGENT_STATUS_KEY, activeAgents > 0 ? String(activeAgents) : undefined);
76
+ }
77
+
70
78
  pi.registerTool({
71
79
  name: "astro_agent",
72
80
  label: "Astro Agent",
@@ -108,34 +116,41 @@ export default function astroAgentsExtension(pi: ExtensionAPI): void {
108
116
  color: agent.color,
109
117
  };
110
118
 
111
- const result = await runAgent({
112
- agent,
113
- task: input.task,
114
- cwd: ctx.cwd,
115
- skillPaths,
116
- signal,
117
- onUpdate: onUpdate
118
- ? (text) => {
119
- onUpdate({
120
- content: [{ type: "text", text }],
121
- details: detailsBase,
122
- });
123
- }
124
- : undefined,
125
- });
126
-
127
- if (result.exitCode !== 0) {
128
- throw new Error(
129
- `Agent "${agent.name}" exited with code ${result.exitCode}.${result.stderr ? `\nstderr:\n${result.stderr}` : ""}`,
130
- );
131
- }
119
+ activeAgents++;
120
+ publishCount(ctx);
121
+ try {
122
+ const result = await runAgent({
123
+ agent,
124
+ task: input.task,
125
+ cwd: ctx.cwd,
126
+ skillPaths,
127
+ signal,
128
+ onUpdate: onUpdate
129
+ ? (text) => {
130
+ onUpdate({
131
+ content: [{ type: "text", text }],
132
+ details: detailsBase,
133
+ });
134
+ }
135
+ : undefined,
136
+ });
137
+
138
+ if (result.exitCode !== 0) {
139
+ throw new Error(
140
+ `Agent "${agent.name}" exited with code ${result.exitCode}.${result.stderr ? `\nstderr:\n${result.stderr}` : ""}`,
141
+ );
142
+ }
132
143
 
133
- const text = result.text || "(agent produced no output)";
144
+ const text = result.text || "(agent produced no output)";
134
145
 
135
- return {
136
- content: [{ type: "text", text }],
137
- details: detailsBase,
138
- };
146
+ return {
147
+ content: [{ type: "text", text }],
148
+ details: detailsBase,
149
+ };
150
+ } finally {
151
+ activeAgents = Math.max(0, activeAgents - 1);
152
+ publishCount(ctx);
153
+ }
139
154
  },
140
155
  });
141
156
  }
@@ -1,63 +1,85 @@
1
1
  # astro-footer
2
2
 
3
- Single-line powerline-style footer for the [pi coding agent](https://github.com/badlogic/pi-mono). Replaces the built-in footer with a compact status line:
3
+ Two-line footer for the [pi coding agent](https://github.com/badlogic/pi-mono). Replaces the built-in footer with a compact status bar that auto-grows to a second row only when there's information to show.
4
4
 
5
5
  ```
6
- π claude-sonnet-4-6 think:med pi-astro main 1.2k $0.05 25% › 🪨 caveman:full
6
+ π | ◈ MoonshotAI: Kimi K2.6 | ? think:high | dir pi-astro | git main +2 *3 ?1 ↑1.2k ↓567 | $ $0.05 | 25.0%/256k AC
7
+ agents 2 | t 12m34s | name before-rewrite | cR 8.5k cW 2.1k | 🪨 caveman:full
7
8
  ```
8
9
 
9
10
  Ships as part of [`@astrofoundry/pi-astro`](../../README.md). No extra install. Auto-activates on every pi session.
10
11
 
11
12
  ## What it shows
12
13
 
13
- **Left side:**
14
+ ### Row 1 (always)
14
15
 
15
16
  | Segment | Value |
16
17
  |---|---|
17
18
  | `π` | Pi marker |
18
- | **model** | Last segment of the active model id (e.g. `claude-sonnet-4-6`) |
19
- | **thinking** | Current thinking level (`think:off` … `think:xhi`), colour-coded |
20
- | **path** | Current working directory's basename |
21
- | **git** | Current branch (hidden when not in a git repo) |
19
+ | **model** | `Provider: Model Name` (e.g. `MoonshotAI: Kimi K2.6`) |
20
+ | **thinking** | `think:off` … `think:xhi`, colour-coded by level |
21
+ | **path** | Current working directory (`basename` by default) |
22
+ | **git** | Current branch + `+N` staged, `*N` unstaged, `?N` untracked. Branch turns yellow when dirty. |
23
+ | **tokens** | `↑input ↓output` for the session |
24
+ | **cost** | Cumulative cost — `$0.05` paid models, `(sub)` when the active model is OAuth-authed (Claude Pro / Codex Pro etc.) |
25
+ | **context** | `<percent>%/<total>` with `AC` suffix when auto-compaction is enabled. Yellow ≥ 70%, red ≥ 90%. |
22
26
 
23
- **Right side:**
27
+ ### Row 2 (only when populated)
24
28
 
25
29
  | Segment | Value |
26
30
  |---|---|
27
- | **tokens** | Cumulative input + output tokens for the session (`1.2k`, `45M`) |
28
- | **cost** | Cumulative session cost (`$0.05`, `$1.23`) |
29
- | **context** | Context window usage as a percentage; **yellow ≥ 70%, red ≥ 90%** |
30
- | **extension statuses** | Anything any extension has published via `ctx.ui.setStatus(key, text)` (e.g. caveman's `🪨 caveman:full`) |
31
+ | **subagents** | Count of in-flight `astro_agent` invocations |
32
+ | **session time** | Time since session start (only after 1 minute) |
33
+ | **session name** | Custom name set via `/name <text>` |
34
+ | **cache** | `cR <read> cW <write>` prompt-cache totals |
35
+ | **extension statuses** | Anything any extension publishes via `ctx.ui.setStatus(key, text)` (e.g. caveman's mode badge) |
31
36
 
32
- Segments hide automatically when the data isn't available (no model, no git, zero cost, etc.).
37
+ The second row appears only when at least one of these has content. Otherwise the footer is a single line.
33
38
 
34
39
  ## Commands
35
40
 
36
41
  | Command | Effect |
37
42
  |---|---|
38
43
  | `/footer` or `/footer status` | Print current state |
39
- | `/footer on` | Enable (re-activate after `/footer off`) |
44
+ | `/footer on` | Re-enable after `/footer off` |
40
45
  | `/footer off` | Restore pi's built-in footer |
41
46
 
42
- Argument autocomplete is wired — type `/footer ` and tab-cycle.
47
+ ## Environment overrides
43
48
 
44
- ## Icons
49
+ ```bash
50
+ ASTRO_FOOTER_NERD_FONTS=1 # force Nerd Font icons
51
+ ASTRO_FOOTER_NERD_FONTS=0 # force ASCII
45
52
 
46
- Auto-detects Nerd Fonts in iTerm, WezTerm, Kitty, Ghostty, Alacritty, and falls back to plain ASCII glyphs everywhere else.
53
+ ASTRO_FOOTER_PATH=basename # default: just the leaf folder
54
+ ASTRO_FOOTER_PATH=abbreviated # collapse middle dirs to … when long (e.g. ~/…/apps/web)
55
+ ASTRO_FOOTER_PATH=full # full home-tilded path (e.g. ~/wb/github/astrofoundry/pi-astro)
56
+ ```
47
57
 
48
- Override the auto-detection:
58
+ ## Auto-compact (`AC`)
49
59
 
50
- ```bash
51
- export ASTRO_FOOTER_NERD_FONTS=1 # force Nerd Font icons
52
- export ASTRO_FOOTER_NERD_FONTS=0 # force ASCII icons
53
- ```
60
+ `AC` reflects pi's `compaction.enabled` setting (default `true`). Read from `<cwd>/.pi/settings.json` first, then `~/.pi/agent/settings.json`, with project overriding global. When `AC` is missing, pi will hit the context wall instead of summarising.
54
61
 
55
- Read at extension load — restart pi after changing.
62
+ ## Subscription detection
56
63
 
57
- ## Extension-status pass-through
64
+ The cost segment shows `(sub)` instead of `$X.XX` when:
65
+
66
+ - `ctx.modelRegistry.isUsingOAuth(model)` returns true — i.e. you're authed via OAuth (Claude / Codex Pro flow), so per-token cost is covered by the subscription.
67
+
68
+ Pi-tracked dollar costs would otherwise mislead you into thinking the call had a real bill.
58
69
 
59
- Any other extension that calls `ctx.ui.setStatus(key, text)` automatically appears on the right side, in muted colour. No config required.
70
+ ## Subagent count
60
71
 
61
- This is what surfaces caveman's mode badge, notify-on-stop's state, and any future extension's status without each rolling its own footer renderer.
72
+ When the `astro-agents` extension's `astro_agent` tool is running, it publishes the in-flight count via `ctx.ui.setStatus("subagents", N)`. astro-footer reads this and surfaces it as the `agents N` segment on row 2. To run multiple subagents concurrently, ask the LLM to fire several `astro_agent` calls in one assistant message — pi runs tool calls in parallel within a turn.
73
+
74
+ ## Git ± counts
75
+
76
+ astro-footer runs `git status --porcelain` async with a 750 ms cooldown. It refreshes:
77
+
78
+ - on session start
79
+ - when an `edit`, `write`, or `bash` tool finishes (debounced)
80
+
81
+ Heavy enough to surface uncommitted-work signal, light enough to never block rendering.
82
+
83
+ ## Extension-status pass-through
62
84
 
63
- To remove an extension's status from the footer, the extension itself calls `ctx.ui.setStatus(key, undefined)`.
85
+ Any extension that calls `ctx.ui.setStatus(key, text)` automatically appears on row 2 in muted colour. The `subagents` key is special-cased into its own segment; everything else pass-through.
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { formatCost, formatPathBasename, formatPercent, formatTokens } from "./format.ts";
2
+ import {
3
+ formatCost,
4
+ formatDuration,
5
+ formatPath,
6
+ formatPathBasename,
7
+ formatPercent,
8
+ formatTokens,
9
+ resolvePathMode,
10
+ } from "./format.ts";
3
11
 
4
12
  describe("formatTokens", () => {
5
13
  it("returns plain integer below 1000", () => {
@@ -61,6 +69,58 @@ describe("formatPercent", () => {
61
69
  });
62
70
  });
63
71
 
72
+ describe("formatDuration", () => {
73
+ it("renders seconds-only under a minute", () => {
74
+ expect(formatDuration(0)).toBe("0s");
75
+ expect(formatDuration(45_000)).toBe("45s");
76
+ });
77
+
78
+ it("renders minutes and seconds under an hour", () => {
79
+ expect(formatDuration(60_000)).toBe("1m00s");
80
+ expect(formatDuration(12 * 60_000 + 34_000)).toBe("12m34s");
81
+ });
82
+
83
+ it("renders hours and minutes for an hour or more", () => {
84
+ expect(formatDuration(60 * 60_000)).toBe("1h00m");
85
+ expect(formatDuration(2 * 60 * 60_000 + 5 * 60_000)).toBe("2h05m");
86
+ });
87
+ });
88
+
89
+ describe("formatPath + resolvePathMode", () => {
90
+ const home = process.env.HOME ?? "";
91
+
92
+ it("basename mode returns the leaf segment", () => {
93
+ expect(formatPath("/Users/astro/wb/foo", "basename")).toBe("foo");
94
+ });
95
+
96
+ it("full mode tildes the home prefix", () => {
97
+ if (!home) return;
98
+ expect(formatPath(`${home}/wb/foo`, "full")).toBe("~/wb/foo");
99
+ });
100
+
101
+ it("abbreviated mode collapses long paths to leading + tail segments", () => {
102
+ if (!home) return;
103
+ const long = `${home}/wb/github/astrofoundry/pi-astro/extensions/astro-footer`;
104
+ const out = formatPath(long, "abbreviated");
105
+ expect(out).toContain("astro-footer");
106
+ expect(out).toContain("…");
107
+ });
108
+
109
+ it("abbreviated mode returns full path when short enough", () => {
110
+ if (!home) return;
111
+ const short = `${home}/foo`;
112
+ expect(formatPath(short, "abbreviated")).toBe("~/foo");
113
+ });
114
+
115
+ it("resolvePathMode reads ASTRO_FOOTER_PATH and falls back to basename", () => {
116
+ expect(resolvePathMode({})).toBe("basename");
117
+ expect(resolvePathMode({ ASTRO_FOOTER_PATH: "full" })).toBe("full");
118
+ expect(resolvePathMode({ ASTRO_FOOTER_PATH: "abbreviated" })).toBe("abbreviated");
119
+ expect(resolvePathMode({ ASTRO_FOOTER_PATH: "BASENAME" })).toBe("basename");
120
+ expect(resolvePathMode({ ASTRO_FOOTER_PATH: "garbage" })).toBe("basename");
121
+ });
122
+ });
123
+
64
124
  describe("formatPathBasename", () => {
65
125
  it("returns last segment for unix path", () => {
66
126
  expect(formatPathBasename("/Users/astro/wb/github/astrofoundry/pi-astro")).toBe("pi-astro");
@@ -1,3 +1,6 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+
1
4
  export function formatTokens(n: number): string {
2
5
  if (n < 1000) return `${n}`;
3
6
  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
@@ -21,3 +24,48 @@ export function formatPathBasename(cwd: string): string {
21
24
  const segments = trimmed.split(/[\\/]/);
22
25
  return segments[segments.length - 1] || trimmed || "/";
23
26
  }
27
+
28
+ export type PathMode = "basename" | "abbreviated" | "full";
29
+
30
+ const ABBREVIATED_MAX = 30;
31
+
32
+ function withHomeTilde(p: string): string {
33
+ const home = os.homedir();
34
+ if (home && (p === home || p.startsWith(`${home}/`) || p.startsWith(`${home}\\`))) {
35
+ return `~${p.slice(home.length)}`;
36
+ }
37
+ return p;
38
+ }
39
+
40
+ export function formatPath(cwd: string, mode: PathMode): string {
41
+ if (mode === "basename") return formatPathBasename(cwd);
42
+ const tilded = withHomeTilde(cwd);
43
+ if (mode === "full") return tilded;
44
+ if (tilded.length <= ABBREVIATED_MAX) return tilded;
45
+ const parts = tilded.split(path.sep);
46
+ if (parts.length <= 2) return tilded;
47
+ let out = `${parts[0]}/…/${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
48
+ if (out.length > ABBREVIATED_MAX) {
49
+ out = `…/${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
50
+ }
51
+ if (out.length > ABBREVIATED_MAX) {
52
+ out = `…/${parts[parts.length - 1]}`;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export function resolvePathMode(env: NodeJS.ProcessEnv = process.env): PathMode {
58
+ const value = (env.ASTRO_FOOTER_PATH ?? "").toLowerCase();
59
+ if (value === "abbreviated" || value === "full" || value === "basename") return value;
60
+ return "basename";
61
+ }
62
+
63
+ export function formatDuration(ms: number): string {
64
+ const totalSeconds = Math.floor(ms / 1000);
65
+ const hours = Math.floor(totalSeconds / 3600);
66
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
67
+ const seconds = totalSeconds % 60;
68
+ if (hours > 0) return `${hours}h${minutes.toString().padStart(2, "0")}m`;
69
+ if (minutes > 0) return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
70
+ return `${seconds}s`;
71
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { countsEqual, parsePorcelain, ZERO_COUNTS } from "./git-status.ts";
3
+
4
+ describe("parsePorcelain", () => {
5
+ it("returns zeros for empty output", () => {
6
+ expect(parsePorcelain("")).toEqual({ staged: 0, unstaged: 0, untracked: 0 });
7
+ });
8
+
9
+ it("counts staged-only entries", () => {
10
+ const out = "M src/a.ts\nA src/b.ts\n";
11
+ expect(parsePorcelain(out)).toEqual({ staged: 2, unstaged: 0, untracked: 0 });
12
+ });
13
+
14
+ it("counts unstaged-only entries", () => {
15
+ const out = " M src/a.ts\n D src/b.ts\n";
16
+ expect(parsePorcelain(out)).toEqual({ staged: 0, unstaged: 2, untracked: 0 });
17
+ });
18
+
19
+ it("counts both staged and unstaged for partially-staged file (MM)", () => {
20
+ const out = "MM src/a.ts\n";
21
+ expect(parsePorcelain(out)).toEqual({ staged: 1, unstaged: 1, untracked: 0 });
22
+ });
23
+
24
+ it("counts untracked entries", () => {
25
+ const out = "?? src/a.ts\n?? src/b.ts\n";
26
+ expect(parsePorcelain(out)).toEqual({ staged: 0, unstaged: 0, untracked: 2 });
27
+ });
28
+
29
+ it("handles a mixed report", () => {
30
+ const out = "M staged.ts\n M unstaged.ts\nMM both.ts\n?? new.ts\n";
31
+ expect(parsePorcelain(out)).toEqual({ staged: 2, unstaged: 2, untracked: 1 });
32
+ });
33
+ });
34
+
35
+ describe("countsEqual", () => {
36
+ it("returns true for identical counts", () => {
37
+ expect(countsEqual(ZERO_COUNTS, { staged: 0, unstaged: 0, untracked: 0 })).toBe(true);
38
+ });
39
+
40
+ it("returns false when any field differs", () => {
41
+ expect(countsEqual(ZERO_COUNTS, { staged: 1, unstaged: 0, untracked: 0 })).toBe(false);
42
+ expect(countsEqual(ZERO_COUNTS, { staged: 0, unstaged: 1, untracked: 0 })).toBe(false);
43
+ expect(countsEqual(ZERO_COUNTS, { staged: 0, unstaged: 0, untracked: 1 })).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,82 @@
1
+ import { exec } from "node:child_process";
2
+
3
+ export interface GitCounts {
4
+ staged: number;
5
+ unstaged: number;
6
+ untracked: number;
7
+ }
8
+
9
+ export const ZERO_COUNTS: GitCounts = { staged: 0, unstaged: 0, untracked: 0 };
10
+
11
+ export function parsePorcelain(output: string): GitCounts {
12
+ const counts: GitCounts = { staged: 0, unstaged: 0, untracked: 0 };
13
+ for (const raw of output.split("\n")) {
14
+ if (raw.length < 2) continue;
15
+ const x = raw[0];
16
+ const y = raw[1];
17
+ if (x === "?" && y === "?") {
18
+ counts.untracked++;
19
+ continue;
20
+ }
21
+ if (x !== " " && x !== "?") counts.staged++;
22
+ if (y !== " " && y !== "?") counts.unstaged++;
23
+ }
24
+ return counts;
25
+ }
26
+
27
+ export function countsEqual(a: GitCounts, b: GitCounts): boolean {
28
+ return a.staged === b.staged && a.unstaged === b.unstaged && a.untracked === b.untracked;
29
+ }
30
+
31
+ const REFRESH_COOLDOWN_MS = 750;
32
+
33
+ export class GitStatusTracker {
34
+ private counts: GitCounts = ZERO_COUNTS;
35
+ private inFlight = false;
36
+ private lastFetchedAt = 0;
37
+ private pendingRefresh = false;
38
+
39
+ constructor(private readonly onChange: () => void = () => {}) {}
40
+
41
+ get(): GitCounts {
42
+ return this.counts;
43
+ }
44
+
45
+ refresh(cwd: string, force = false): void {
46
+ const now = Date.now();
47
+ if (this.inFlight) {
48
+ this.pendingRefresh = true;
49
+ return;
50
+ }
51
+ if (!force && now - this.lastFetchedAt < REFRESH_COOLDOWN_MS) {
52
+ return;
53
+ }
54
+ this.runRefresh(cwd);
55
+ }
56
+
57
+ private runRefresh(cwd: string): void {
58
+ this.inFlight = true;
59
+ exec(
60
+ "git status --porcelain",
61
+ { cwd, timeout: 2000, maxBuffer: 1024 * 1024 },
62
+ (err, stdout) => {
63
+ this.inFlight = false;
64
+ this.lastFetchedAt = Date.now();
65
+ if (!err) {
66
+ const next = parsePorcelain(stdout);
67
+ if (!countsEqual(this.counts, next)) {
68
+ this.counts = next;
69
+ this.onChange();
70
+ }
71
+ } else if (!countsEqual(this.counts, ZERO_COUNTS)) {
72
+ this.counts = ZERO_COUNTS;
73
+ this.onChange();
74
+ }
75
+ if (this.pendingRefresh) {
76
+ this.pendingRefresh = false;
77
+ this.runRefresh(cwd);
78
+ }
79
+ },
80
+ );
81
+ }
82
+ }
@@ -4,9 +4,16 @@ export interface IconSet {
4
4
  thinking: string;
5
5
  path: string;
6
6
  git: string;
7
- tokens: string;
7
+ tokensIn: string;
8
+ tokensOut: string;
8
9
  cost: string;
9
10
  context: string;
11
+ autoCompact: string;
12
+ subagents: string;
13
+ sessionTime: string;
14
+ sessionName: string;
15
+ cacheRead: string;
16
+ cacheWrite: string;
10
17
  separator: string;
11
18
  }
12
19
 
@@ -16,9 +23,16 @@ const NERD: IconSet = {
16
23
  thinking: "",
17
24
  path: "dir",
18
25
  git: "",
19
- tokens: "",
26
+ tokensIn: "",
27
+ tokensOut: "↓",
20
28
  cost: "",
21
29
  context: "◫",
30
+ autoCompact: "AC",
31
+ subagents: "",
32
+ sessionTime: "",
33
+ sessionName: "",
34
+ cacheRead: "cR",
35
+ cacheWrite: "cW",
22
36
  separator: "|",
23
37
  };
24
38
 
@@ -28,9 +42,16 @@ const ASCII: IconSet = {
28
42
  thinking: "?",
29
43
  path: "dir",
30
44
  git: "git",
31
- tokens: "tok",
45
+ tokensIn: "",
46
+ tokensOut: "↓",
32
47
  cost: "$",
33
48
  context: "◫",
49
+ autoCompact: "AC",
50
+ subagents: "agents",
51
+ sessionTime: "t",
52
+ sessionName: "name",
53
+ cacheRead: "cR",
54
+ cacheWrite: "cW",
34
55
  separator: "|",
35
56
  };
36
57