@astrofoundry/pi-astro 0.12.2 → 0.13.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.
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { iconsFor } from "./icons.ts";
3
+ import { ZERO_COUNTS } from "./git-status.ts";
3
4
  import {
5
+ renderCache,
4
6
  renderContext,
5
7
  renderCost,
6
8
  renderExtensionStatus,
@@ -8,6 +10,9 @@ import {
8
10
  renderModel,
9
11
  renderPath,
10
12
  renderPi,
13
+ renderSessionName,
14
+ renderSessionTime,
15
+ renderSubagents,
11
16
  renderThinking,
12
17
  renderTokens,
13
18
  type ThemeFn,
@@ -48,69 +53,156 @@ describe("segments", () => {
48
53
  });
49
54
 
50
55
  it("renderThinking falls back gracefully on unknown level", () => {
51
- const out = renderThinking(fakeTheme, ICONS, "weird");
52
- expect(out).toContain("think:weird");
56
+ expect(renderThinking(fakeTheme, ICONS, "weird")).toContain("think:weird");
53
57
  });
54
58
 
55
- it("renderPath shows the basename of cwd", () => {
56
- const out = renderPath(fakeTheme, ICONS, "/Users/astro/proj");
59
+ it("renderPath shows the basename of cwd in basename mode", () => {
60
+ const out = renderPath(fakeTheme, ICONS, "/Users/astro/proj", "basename");
57
61
  expect(out).toContain("proj");
58
62
  });
59
63
 
60
64
  it("renderGit returns null when no branch", () => {
61
- expect(renderGit(fakeTheme, ICONS, null)).toBeNull();
65
+ expect(renderGit(fakeTheme, ICONS, null, ZERO_COUNTS)).toBeNull();
62
66
  });
63
67
 
64
- it("renderGit shows branch name in success colour", () => {
65
- const out = renderGit(fakeTheme, ICONS, "feat-foo");
66
- expect(out).toContain("<success>feat-foo</success>");
68
+ it("renderGit shows branch in success colour when clean", () => {
69
+ const out = renderGit(fakeTheme, ICONS, "main", ZERO_COUNTS);
70
+ expect(out).toContain("<success>main</success>");
71
+ expect(out).not.toContain("+");
72
+ expect(out).not.toContain("*");
67
73
  });
68
74
 
69
- it("renderTokens hides when total is zero", () => {
70
- expect(renderTokens(fakeTheme, ICONS, 0)).toBeNull();
75
+ it("renderGit shows ± counts and warning colour when dirty", () => {
76
+ const out = renderGit(fakeTheme, ICONS, "feat", { staged: 2, unstaged: 3, untracked: 1 });
77
+ expect(out).toContain("<warning>feat</warning>");
78
+ expect(out).toContain("<success>+2</success>");
79
+ expect(out).toContain("<warning>*3</warning>");
80
+ expect(out).toContain("<muted>?1</muted>");
71
81
  });
72
82
 
73
- it("renderTokens formats large totals", () => {
74
- expect(renderTokens(fakeTheme, ICONS, 1234)).toContain("1.2k");
83
+ it("renderTokens hides when both totals are zero", () => {
84
+ expect(renderTokens(fakeTheme, ICONS, 0, 0)).toBeNull();
75
85
  });
76
86
 
77
- it("renderCost hides when zero", () => {
78
- expect(renderCost(fakeTheme, ICONS, 0)).toBeNull();
87
+ it("renderTokens splits input/output with arrows", () => {
88
+ const out = renderTokens(fakeTheme, ICONS, 1234, 567);
89
+ expect(out).toContain("↑");
90
+ expect(out).toContain("1.2k");
91
+ expect(out).toContain("↓");
92
+ expect(out).toContain("567");
79
93
  });
80
94
 
81
- it("renderCost formats sub-dollar costs", () => {
82
- expect(renderCost(fakeTheme, ICONS, 0.123)).toContain("$0.123");
95
+ it("renderCost shows (sub) when subscription-covered", () => {
96
+ const out = renderCost(fakeTheme, ICONS, { costUsd: 0, isSubscription: true });
97
+ expect(out).toContain("<success>(sub)</success>");
83
98
  });
84
99
 
85
- it("renderContext hides when input is null or percent is null", () => {
100
+ it("renderCost hides when zero and not subscription", () => {
101
+ expect(renderCost(fakeTheme, ICONS, { costUsd: 0, isSubscription: false })).toBeNull();
102
+ });
103
+
104
+ it("renderCost formats sub-dollar costs when not subscription", () => {
105
+ const out = renderCost(fakeTheme, ICONS, { costUsd: 0.123, isSubscription: false });
106
+ expect(out).toContain("$0.123");
107
+ });
108
+
109
+ it("renderContext hides when input is null or percent null", () => {
86
110
  expect(renderContext(fakeTheme, ICONS, null)).toBeNull();
87
- expect(renderContext(fakeTheme, ICONS, { percent: null, contextWindow: 256_000 })).toBeNull();
111
+ expect(
112
+ renderContext(fakeTheme, ICONS, { percent: null, contextWindow: 256_000, autoCompact: true }),
113
+ ).toBeNull();
88
114
  });
89
115
 
90
116
  it("renderContext uses dim colour below 70%", () => {
91
- const out = renderContext(fakeTheme, ICONS, { percent: 50, contextWindow: 256_000 });
117
+ const out = renderContext(fakeTheme, ICONS, {
118
+ percent: 50,
119
+ contextWindow: 256_000,
120
+ autoCompact: false,
121
+ });
92
122
  expect(out).toContain("<dim>");
93
123
  expect(out).toContain("50.0%/256k");
94
124
  });
95
125
 
96
- it("renderContext uses warning colour at and above 70%", () => {
97
- const out = renderContext(fakeTheme, ICONS, { percent: 70, contextWindow: 200_000 });
126
+ it("renderContext uses warning at 70%", () => {
127
+ const out = renderContext(fakeTheme, ICONS, {
128
+ percent: 70,
129
+ contextWindow: 200_000,
130
+ autoCompact: false,
131
+ });
98
132
  expect(out).toContain("<warning>");
99
133
  expect(out).toContain("70.0%/200k");
100
134
  });
101
135
 
102
- it("renderContext uses error colour at and above 90%", () => {
103
- const out = renderContext(fakeTheme, ICONS, { percent: 95.5, contextWindow: 1_000_000 });
136
+ it("renderContext uses error at 90%+", () => {
137
+ const out = renderContext(fakeTheme, ICONS, {
138
+ percent: 95.5,
139
+ contextWindow: 1_000_000,
140
+ autoCompact: false,
141
+ });
104
142
  expect(out).toContain("<error>");
105
143
  expect(out).toContain("95.5%/1.0M");
106
144
  });
107
145
 
146
+ it("renderContext appends AC indicator when auto-compact is on", () => {
147
+ const out = renderContext(fakeTheme, ICONS, {
148
+ percent: 25,
149
+ contextWindow: 256_000,
150
+ autoCompact: true,
151
+ });
152
+ expect(out).toContain("AC");
153
+ });
154
+
108
155
  it("renderContext omits the /total suffix when contextWindow is missing", () => {
109
- const out = renderContext(fakeTheme, ICONS, { percent: 25, contextWindow: 0 });
156
+ const out = renderContext(fakeTheme, ICONS, {
157
+ percent: 25,
158
+ contextWindow: 0,
159
+ autoCompact: false,
160
+ });
110
161
  expect(out).toContain("25.0%");
111
162
  expect(out).not.toContain("25.0%/");
112
163
  });
113
164
 
165
+ it("renderSubagents hides when count is 0", () => {
166
+ expect(renderSubagents(fakeTheme, ICONS, 0)).toBeNull();
167
+ });
168
+
169
+ it("renderSubagents shows the count in accent colour", () => {
170
+ const out = renderSubagents(fakeTheme, ICONS, 3);
171
+ expect(out).toContain("<accent>3</accent>");
172
+ });
173
+
174
+ it("renderSessionTime hides under 60 seconds", () => {
175
+ expect(renderSessionTime(fakeTheme, ICONS, 0)).toBeNull();
176
+ expect(renderSessionTime(fakeTheme, ICONS, 30_000)).toBeNull();
177
+ expect(renderSessionTime(fakeTheme, ICONS, 59_999)).toBeNull();
178
+ });
179
+
180
+ it("renderSessionTime formats minutes and seconds at and above 1 minute", () => {
181
+ const out = renderSessionTime(fakeTheme, ICONS, 12 * 60_000 + 34_000);
182
+ expect(out).toContain("12m34s");
183
+ });
184
+
185
+ it("renderSessionName hides when undefined", () => {
186
+ expect(renderSessionName(fakeTheme, ICONS, undefined)).toBeNull();
187
+ });
188
+
189
+ it("renderSessionName shows the name", () => {
190
+ const out = renderSessionName(fakeTheme, ICONS, "before-rewrite");
191
+ expect(out).toContain("before-rewrite");
192
+ });
193
+
194
+ it("renderCache hides when both are 0", () => {
195
+ expect(renderCache(fakeTheme, ICONS, 0, 0)).toBeNull();
196
+ });
197
+
198
+ it("renderCache shows both counts with cR/cW prefixes", () => {
199
+ const out = renderCache(fakeTheme, ICONS, 8500, 2100);
200
+ expect(out).toContain("cR");
201
+ expect(out).toContain("8.5k");
202
+ expect(out).toContain("cW");
203
+ expect(out).toContain("2.1k");
204
+ });
205
+
114
206
  it("renderExtensionStatus uses muted colour", () => {
115
207
  expect(renderExtensionStatus(fakeTheme, "🪨 caveman:full")).toBe("<muted>🪨 caveman:full</muted>");
116
208
  });
@@ -1,6 +1,14 @@
1
1
  import type { ThemeColor } from "@mariozechner/pi-coding-agent";
2
2
  import type { IconSet } from "./icons.ts";
3
- import { formatCost, formatPathBasename, formatPercent, formatTokens } from "./format.ts";
3
+ import {
4
+ formatCost,
5
+ formatDuration,
6
+ formatPath,
7
+ formatPercent,
8
+ formatTokens,
9
+ type PathMode,
10
+ } from "./format.ts";
11
+ import type { GitCounts } from "./git-status.ts";
4
12
  import { formatModelDisplay } from "./model-name.ts";
5
13
 
6
14
  export interface ThemeFn {
@@ -42,41 +50,92 @@ export function renderThinking(theme: ThemeFn, icons: IconSet, level: string | u
42
50
  return `${theme.fg(color, icons.thinking)} ${theme.fg(color, `think:${label}`)}`.trim();
43
51
  }
44
52
 
45
- export function renderPath(theme: ThemeFn, icons: IconSet, cwd: string): string {
46
- const name = formatPathBasename(cwd);
53
+ export function renderPath(theme: ThemeFn, icons: IconSet, cwd: string, mode: PathMode): string {
54
+ const name = formatPath(cwd, mode);
47
55
  return `${theme.fg("dim", icons.path)} ${theme.fg("accent", name)}`.trim();
48
56
  }
49
57
 
50
- export function renderGit(theme: ThemeFn, icons: IconSet, branch: string | null): string | null {
58
+ export function renderGit(
59
+ theme: ThemeFn,
60
+ icons: IconSet,
61
+ branch: string | null,
62
+ counts: GitCounts,
63
+ ): string | null {
51
64
  if (!branch) return null;
52
- return `${theme.fg("success", icons.git)} ${theme.fg("success", branch)}`.trim();
65
+ const dirty = counts.staged > 0 || counts.unstaged > 0 || counts.untracked > 0;
66
+ const role: ThemeColor = dirty ? "warning" : "success";
67
+ const parts: string[] = [];
68
+ parts.push(theme.fg(role, icons.git));
69
+ parts.push(theme.fg(role, branch));
70
+ if (counts.staged > 0) parts.push(theme.fg("success", `+${counts.staged}`));
71
+ if (counts.unstaged > 0) parts.push(theme.fg("warning", `*${counts.unstaged}`));
72
+ if (counts.untracked > 0) parts.push(theme.fg("muted", `?${counts.untracked}`));
73
+ return parts.join(" ");
74
+ }
75
+
76
+ export function renderTokens(
77
+ theme: ThemeFn,
78
+ icons: IconSet,
79
+ inputTokens: number,
80
+ outputTokens: number,
81
+ ): string | null {
82
+ if (inputTokens === 0 && outputTokens === 0) return null;
83
+ return `${theme.fg("muted", icons.tokensIn)}${theme.fg("text", formatTokens(inputTokens))} ${theme.fg("muted", icons.tokensOut)}${theme.fg("text", formatTokens(outputTokens))}`;
53
84
  }
54
85
 
55
- export function renderTokens(theme: ThemeFn, icons: IconSet, totalTokens: number): string | null {
56
- if (totalTokens === 0) return null;
57
- return `${theme.fg("muted", icons.tokens)} ${theme.fg("text", formatTokens(totalTokens))}`.trim();
86
+ export interface CostInput {
87
+ costUsd: number;
88
+ isSubscription: boolean;
58
89
  }
59
90
 
60
- export function renderCost(theme: ThemeFn, icons: IconSet, costUsd: number): string | null {
61
- if (costUsd === 0) return null;
62
- return `${theme.fg("muted", icons.cost)} ${theme.fg("text", formatCost(costUsd))}`.trim();
91
+ export function renderCost(theme: ThemeFn, icons: IconSet, input: CostInput): string | null {
92
+ if (input.isSubscription) {
93
+ return `${theme.fg("muted", icons.cost)} ${theme.fg("success", "(sub)")}`.trim();
94
+ }
95
+ if (input.costUsd === 0) return null;
96
+ return `${theme.fg("muted", icons.cost)} ${theme.fg("text", formatCost(input.costUsd))}`.trim();
63
97
  }
64
98
 
65
99
  export interface ContextSegmentInput {
66
100
  percent: number | null;
67
101
  contextWindow: number | null;
102
+ autoCompact: boolean;
68
103
  }
69
104
 
70
- export function renderContext(
71
- theme: ThemeFn,
72
- icons: IconSet,
73
- input: ContextSegmentInput | null,
74
- ): string | null {
105
+ export function renderContext(theme: ThemeFn, icons: IconSet, input: ContextSegmentInput | null): string | null {
75
106
  if (!input || input.percent === null) return null;
76
107
  const role: ThemeColor = input.percent >= 90 ? "error" : input.percent >= 70 ? "warning" : "dim";
77
108
  const total = input.contextWindow && input.contextWindow > 0 ? `/${formatTokens(input.contextWindow)}` : "";
78
109
  const value = `${formatPercent(input.percent)}${total}`;
79
- return `${theme.fg(role, icons.context)} ${theme.fg(role, value)}`.trim();
110
+ const ac = input.autoCompact ? ` ${theme.fg("muted", icons.autoCompact)}` : "";
111
+ return `${theme.fg(role, icons.context)} ${theme.fg(role, value)}${ac}`;
112
+ }
113
+
114
+ export function renderSubagents(theme: ThemeFn, icons: IconSet, count: number): string | null {
115
+ if (count <= 0) return null;
116
+ return `${theme.fg("muted", icons.subagents)} ${theme.fg("accent", `${count}`)}`.trim();
117
+ }
118
+
119
+ const SESSION_TIME_MIN_MS = 60_000;
120
+
121
+ export function renderSessionTime(theme: ThemeFn, icons: IconSet, ms: number): string | null {
122
+ if (ms < SESSION_TIME_MIN_MS) return null;
123
+ return `${theme.fg("muted", icons.sessionTime)} ${theme.fg("text", formatDuration(ms))}`.trim();
124
+ }
125
+
126
+ export function renderSessionName(theme: ThemeFn, icons: IconSet, name: string | undefined): string | null {
127
+ if (!name) return null;
128
+ return `${theme.fg("muted", icons.sessionName)} ${theme.fg("text", name)}`.trim();
129
+ }
130
+
131
+ export function renderCache(
132
+ theme: ThemeFn,
133
+ icons: IconSet,
134
+ cacheRead: number,
135
+ cacheWrite: number,
136
+ ): string | null {
137
+ if (cacheRead === 0 && cacheWrite === 0) return null;
138
+ return `${theme.fg("muted", icons.cacheRead)}${theme.fg("text", formatTokens(cacheRead))} ${theme.fg("muted", icons.cacheWrite)}${theme.fg("text", formatTokens(cacheWrite))}`;
80
139
  }
81
140
 
82
141
  export function renderExtensionStatus(theme: ThemeFn, value: string): string {
@@ -0,0 +1,85 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ DEFAULT_AUTO_COMPACT,
7
+ globalSettingsPath,
8
+ isAutoCompactEnabled,
9
+ projectSettingsPath,
10
+ } from "./settings.ts";
11
+
12
+ describe("settings paths", () => {
13
+ it("globalSettingsPath uses PI_CODING_AGENT_DIR when set", () => {
14
+ const out = globalSettingsPath({ PI_CODING_AGENT_DIR: "/custom/dir" });
15
+ expect(out).toBe(path.join("/custom/dir", "settings.json"));
16
+ });
17
+
18
+ it("globalSettingsPath defaults to ~/.pi/agent/settings.json", () => {
19
+ const out = globalSettingsPath({});
20
+ expect(out).toBe(path.join(os.homedir(), ".pi", "agent", "settings.json"));
21
+ });
22
+
23
+ it("projectSettingsPath joins .pi/settings.json under cwd", () => {
24
+ expect(projectSettingsPath("/tmp/proj")).toBe(path.join("/tmp/proj", ".pi", "settings.json"));
25
+ });
26
+ });
27
+
28
+ describe("isAutoCompactEnabled", () => {
29
+ let tmpDir: string;
30
+ let projectDir: string;
31
+ let globalDir: string;
32
+
33
+ beforeEach(() => {
34
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "astro-footer-settings-"));
35
+ projectDir = path.join(tmpDir, "proj");
36
+ globalDir = path.join(tmpDir, "global");
37
+ fs.mkdirSync(path.join(projectDir, ".pi"), { recursive: true });
38
+ fs.mkdirSync(globalDir, { recursive: true });
39
+ });
40
+
41
+ afterEach(() => {
42
+ fs.rmSync(tmpDir, { recursive: true, force: true });
43
+ });
44
+
45
+ function env(): NodeJS.ProcessEnv {
46
+ return { PI_CODING_AGENT_DIR: globalDir };
47
+ }
48
+
49
+ it("returns the documented default when no settings exist", () => {
50
+ expect(isAutoCompactEnabled(projectDir, env())).toBe(DEFAULT_AUTO_COMPACT);
51
+ });
52
+
53
+ it("project setting wins over global", () => {
54
+ fs.writeFileSync(
55
+ path.join(projectDir, ".pi", "settings.json"),
56
+ JSON.stringify({ compaction: { enabled: false } }),
57
+ );
58
+ fs.writeFileSync(
59
+ path.join(globalDir, "settings.json"),
60
+ JSON.stringify({ compaction: { enabled: true } }),
61
+ );
62
+ expect(isAutoCompactEnabled(projectDir, env())).toBe(false);
63
+ });
64
+
65
+ it("falls back to global when project absent", () => {
66
+ fs.writeFileSync(
67
+ path.join(globalDir, "settings.json"),
68
+ JSON.stringify({ compaction: { enabled: false } }),
69
+ );
70
+ expect(isAutoCompactEnabled(projectDir, env())).toBe(false);
71
+ });
72
+
73
+ it("ignores non-boolean values and uses default", () => {
74
+ fs.writeFileSync(
75
+ path.join(globalDir, "settings.json"),
76
+ JSON.stringify({ compaction: { enabled: "yes" } }),
77
+ );
78
+ expect(isAutoCompactEnabled(projectDir, env())).toBe(DEFAULT_AUTO_COMPACT);
79
+ });
80
+
81
+ it("returns the default when settings.json is malformed", () => {
82
+ fs.writeFileSync(path.join(globalDir, "settings.json"), "{not-json");
83
+ expect(isAutoCompactEnabled(projectDir, env())).toBe(DEFAULT_AUTO_COMPACT);
84
+ });
85
+ });
@@ -0,0 +1,43 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export const DEFAULT_AUTO_COMPACT = true;
6
+
7
+ interface CompactionShape {
8
+ compaction?: { enabled?: unknown };
9
+ }
10
+
11
+ function readJsonOrNull(filePath: string): CompactionShape | null {
12
+ if (!fs.existsSync(filePath)) return null;
13
+ const raw = fs.readFileSync(filePath, "utf8");
14
+ return JSON.parse(raw) as CompactionShape;
15
+ }
16
+
17
+ export function globalSettingsPath(env: NodeJS.ProcessEnv = process.env): string {
18
+ const override = env.PI_CODING_AGENT_DIR;
19
+ const dir = override && override.length > 0 ? override : path.join(os.homedir(), ".pi", "agent");
20
+ return path.join(dir, "settings.json");
21
+ }
22
+
23
+ export function projectSettingsPath(cwd: string): string {
24
+ return path.join(cwd, ".pi", "settings.json");
25
+ }
26
+
27
+ export function isAutoCompactEnabled(cwd: string, env: NodeJS.ProcessEnv = process.env): boolean {
28
+ try {
29
+ const project = readJsonOrNull(projectSettingsPath(cwd));
30
+ const projectValue = project?.compaction?.enabled;
31
+ if (typeof projectValue === "boolean") return projectValue;
32
+ } catch {
33
+ /* fall through */
34
+ }
35
+ try {
36
+ const global = readJsonOrNull(globalSettingsPath(env));
37
+ const globalValue = global?.compaction?.enabled;
38
+ if (typeof globalValue === "boolean") return globalValue;
39
+ } catch {
40
+ /* fall through */
41
+ }
42
+ return DEFAULT_AUTO_COMPACT;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"