@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 +1 -1
- package/extensions/astro-agents/index.test.ts +7 -1
- package/extensions/astro-agents/index.ts +41 -26
- package/extensions/astro-footer/README.md +49 -27
- package/extensions/astro-footer/format.test.ts +61 -1
- package/extensions/astro-footer/format.ts +48 -0
- package/extensions/astro-footer/git-status.test.ts +45 -0
- package/extensions/astro-footer/git-status.ts +82 -0
- package/extensions/astro-footer/icons.ts +24 -3
- package/extensions/astro-footer/index.test.ts +168 -47
- package/extensions/astro-footer/index.ts +150 -33
- package/extensions/astro-footer/segments.test.ts +116 -24
- package/extensions/astro-footer/segments.ts +76 -17
- package/extensions/astro-footer/settings.test.ts +85 -0
- package/extensions/astro-footer/settings.ts +43 -0
- package/extensions/astro-snake/README.md +0 -10
- package/extensions/astro-snake/component.ts +0 -11
- package/package.json +1 -1
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` —
|
|
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: {
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
144
|
+
const text = result.text || "(agent produced no output)";
|
|
134
145
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
π
|
|
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
|
-
|
|
14
|
+
### Row 1 (always)
|
|
14
15
|
|
|
15
16
|
| Segment | Value |
|
|
16
17
|
|---|---|
|
|
17
18
|
| `π` | Pi marker |
|
|
18
|
-
| **model** |
|
|
19
|
-
| **thinking** |
|
|
20
|
-
| **path** | Current working directory
|
|
21
|
-
| **git** | Current branch
|
|
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
|
-
|
|
27
|
+
### Row 2 (only when populated)
|
|
24
28
|
|
|
25
29
|
| Segment | Value |
|
|
26
30
|
|---|---|
|
|
27
|
-
| **
|
|
28
|
-
| **
|
|
29
|
-
| **
|
|
30
|
-
| **
|
|
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
|
-
|
|
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` |
|
|
44
|
+
| `/footer on` | Re-enable after `/footer off` |
|
|
40
45
|
| `/footer off` | Restore pi's built-in footer |
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
## Environment overrides
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
```bash
|
|
50
|
+
ASTRO_FOOTER_NERD_FONTS=1 # force Nerd Font icons
|
|
51
|
+
ASTRO_FOOTER_NERD_FONTS=0 # force ASCII
|
|
45
52
|
|
|
46
|
-
|
|
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
|
-
|
|
58
|
+
## Auto-compact (`AC`)
|
|
49
59
|
|
|
50
|
-
|
|
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
|
-
|
|
62
|
+
## Subscription detection
|
|
56
63
|
|
|
57
|
-
|
|
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
|
-
|
|
70
|
+
## Subagent count
|
|
60
71
|
|
|
61
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|