@astrofoundry/pi-astro 0.14.1 → 0.14.3

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.
@@ -6,6 +6,7 @@ const baseStats: WelcomeStats = {
6
6
  contextWindow: 1_000_000,
7
7
  extensionCount: 9,
8
8
  cavemanLevel: undefined,
9
+ pkgVersion: "0.14.2",
9
10
  };
10
11
 
11
12
  describe("composeWelcome", () => {
@@ -48,6 +49,16 @@ describe("composeWelcome", () => {
48
49
  expect(() => composeWelcome(40, 1, baseStats)).not.toThrow();
49
50
  });
50
51
 
52
+ it("renders the version subtitle when pkgVersion is provided", () => {
53
+ const lines = composeWelcome(120, 1, baseStats);
54
+ expect(lines.join("\n")).toContain("v0.14.2");
55
+ });
56
+
57
+ it("omits the version row when pkgVersion is undefined", () => {
58
+ const lines = composeWelcome(120, 1, { ...baseStats, pkgVersion: undefined });
59
+ expect(lines.join("\n")).not.toMatch(/v\d+\.\d+\.\d+/);
60
+ });
61
+
51
62
  it("produces deterministic output for the same seed", () => {
52
63
  const a = composeWelcome(120, 99, baseStats);
53
64
  const b = composeWelcome(120, 99, baseStats);
@@ -9,6 +9,7 @@ export interface WelcomeStats {
9
9
  contextWindow: number | undefined;
10
10
  extensionCount: number;
11
11
  cavemanLevel: string | undefined;
12
+ pkgVersion: string | undefined;
12
13
  }
13
14
 
14
15
  const TITLE = "★ ASTRO PI ★";
@@ -69,10 +70,12 @@ class CompositeMask implements Mask {
69
70
  }
70
71
 
71
72
  export function composeWelcome(width: number, seed: number, stats: WelcomeStats): string[] {
72
- const totalRows = ART_HEIGHT + 7;
73
+ const hasVersion = !!stats.pkgVersion;
74
+ const versionRow = 1;
75
+ const artStartRow = hasVersion ? 2 : 1;
76
+ const totalRows = artStartRow + ART_HEIGHT + 6;
73
77
  const safeWidth = Math.max(40, width);
74
78
  const artStartCol = Math.max(0, Math.floor((safeWidth - ART_WIDTH) / 2));
75
- const artStartRow = 1;
76
79
 
77
80
  const mask = new CompositeMask(totalRows, safeWidth);
78
81
  mask.occupyArt(artStartRow, artStartCol, ART_HEIGHT, ART_WIDTH);
@@ -81,6 +84,13 @@ export function composeWelcome(width: number, seed: number, stats: WelcomeStats)
81
84
  const titlePlaced = centerInLine(titleLine, safeWidth);
82
85
  mask.occupy(0, titlePlaced.col, visibleWidth(titleLine));
83
86
 
87
+ let versionPlaced: { line: string; col: number } | null = null;
88
+ if (hasVersion) {
89
+ const versionLine = fg(PALETTE.hint, `v${stats.pkgVersion}`);
90
+ versionPlaced = centerInLine(versionLine, safeWidth);
91
+ mask.occupy(versionRow, versionPlaced.col, visibleWidth(versionLine));
92
+ }
93
+
84
94
  const taglineRaw = pickMessage(seed);
85
95
  const taglineLine = fg(PALETTE.tagline, taglineRaw);
86
96
  const taglineRow = artStartRow + ART_HEIGHT + 1;
@@ -106,6 +116,7 @@ export function composeWelcome(width: number, seed: number, stats: WelcomeStats)
106
116
  let line = stars[r] ?? " ".repeat(safeWidth);
107
117
 
108
118
  if (r === 0) line = overlay(line, titlePlaced.line, safeWidth);
119
+ if (versionPlaced && r === versionRow) line = overlay(line, versionPlaced.line, safeWidth);
109
120
  if (r === taglineRow) line = overlay(line, taglinePlaced.line, safeWidth);
110
121
  if (r === statsRow) line = overlay(line, statsPlaced.line, safeWidth);
111
122
  if (r === hintRow) line = overlay(line, hintPlaced.line, safeWidth);
@@ -23,35 +23,65 @@ interface FakeCtx {
23
23
  hasUI: boolean;
24
24
  cwd: string;
25
25
  model: { id: string; contextWindow: number } | undefined;
26
- sessionManager: { getEntries: () => unknown[] };
26
+ sessionManager: { getEntries: () => unknown[]; getBranch: () => unknown[] };
27
27
  ui: {
28
- setHeader: ReturnType<typeof vi.fn>;
29
- onTerminalInput: ReturnType<typeof vi.fn>;
28
+ custom: ReturnType<typeof vi.fn>;
30
29
  };
31
30
  }
32
31
 
33
- function makeCtx(overrides: Partial<FakeCtx> = {}): FakeCtx & { _terminalListener: ((data: string) => unknown) | null } {
34
- const state = { listener: null as ((data: string) => unknown) | null };
35
- const onTerminalInput = vi.fn((handler: (data: string) => unknown) => {
36
- state.listener = handler;
37
- return () => {
38
- state.listener = null;
39
- };
40
- });
32
+ interface CapturedComponent {
33
+ invalidate(): void;
34
+ render(width: number): string[];
35
+ handleInput?(data: string): void;
36
+ dispose?(): void;
37
+ }
38
+
39
+ interface CapturedFactory {
40
+ options: unknown;
41
+ done: (result: unknown) => void;
42
+ component: CapturedComponent | null;
43
+ settle: Promise<unknown>;
44
+ }
45
+
46
+ function makeCtx(overrides: Partial<FakeCtx> = {}): FakeCtx & { _factory: CapturedFactory | null } {
47
+ const state: { factory: CapturedFactory | null } = { factory: null };
48
+ const customMock = vi.fn(
49
+ (
50
+ factory: (
51
+ tui: { requestRender: () => void },
52
+ theme: unknown,
53
+ kb: unknown,
54
+ done: (result: unknown) => void,
55
+ ) => CapturedComponent,
56
+ options: unknown,
57
+ ) => {
58
+ let resolveSettle: (r: unknown) => void = () => {};
59
+ const settle = new Promise<unknown>((resolve) => {
60
+ resolveSettle = resolve;
61
+ });
62
+ const cap: CapturedFactory = {
63
+ options,
64
+ done: (r: unknown) => resolveSettle(r),
65
+ component: null,
66
+ settle,
67
+ };
68
+ state.factory = cap;
69
+ const tui = { requestRender: vi.fn() };
70
+ cap.component = factory(tui, {}, {}, cap.done);
71
+ return cap.settle;
72
+ },
73
+ );
41
74
  return {
42
75
  hasUI: true,
43
76
  cwd: "/Users/astro/proj",
44
77
  model: { id: "anthropic/claude-sonnet-4-6", contextWindow: 1_000_000 },
45
- sessionManager: { getEntries: () => [] },
46
- ui: {
47
- setHeader: vi.fn(),
48
- onTerminalInput,
49
- },
50
- get _terminalListener() {
51
- return state.listener;
78
+ sessionManager: { getEntries: () => [], getBranch: () => [] },
79
+ ui: { custom: customMock },
80
+ get _factory() {
81
+ return state.factory;
52
82
  },
53
83
  ...overrides,
54
- } as FakeCtx & { _terminalListener: ((data: string) => unknown) | null };
84
+ } as FakeCtx & { _factory: CapturedFactory | null };
55
85
  }
56
86
 
57
87
  describe("astro-welcome extension", () => {
@@ -72,75 +102,116 @@ describe("astro-welcome extension", () => {
72
102
  return { pi, mod };
73
103
  }
74
104
 
75
- it("registers session_start and before_agent_start listeners", async () => {
105
+ it("registers session_start and before_agent_start handlers", async () => {
76
106
  const { pi } = await install();
77
107
  expect(pi.handlers.session_start).toBeDefined();
78
108
  expect(pi.handlers.before_agent_start).toBeDefined();
79
109
  });
80
110
 
81
- it("on startup session_start, sets a header and arms a terminal-input listener", async () => {
111
+ it("session_start opens a centered overlay via ctx.ui.custom (after a short delay)", async () => {
82
112
  const { pi } = await install();
83
113
  const ctx = makeCtx();
84
- await pi.handlers.session_start({ reason: "startup" }, ctx);
85
- expect(ctx.ui.setHeader).toHaveBeenCalledTimes(1);
86
- expect(ctx.ui.onTerminalInput).toHaveBeenCalledTimes(1);
114
+ await pi.handlers.session_start({}, ctx);
115
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
116
+ vi.advanceTimersByTime(100);
117
+ expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
118
+ const opts = ctx._factory!.options as { overlay: boolean; overlayOptions: { anchor: string } };
119
+ expect(opts.overlay).toBe(true);
120
+ expect(opts.overlayOptions.anchor).toBe("center");
87
121
  });
88
122
 
89
123
  it("does nothing when ctx.hasUI is false", async () => {
90
124
  const { pi } = await install();
91
125
  const ctx = makeCtx({ hasUI: false });
92
- await pi.handlers.session_start({ reason: "startup" }, ctx);
93
- expect(ctx.ui.setHeader).not.toHaveBeenCalled();
126
+ await pi.handlers.session_start({}, ctx);
127
+ vi.advanceTimersByTime(100);
128
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("does not show welcome a second time within the same process", async () => {
132
+ const { pi } = await install();
133
+ const ctx1 = makeCtx();
134
+ await pi.handlers.session_start({}, ctx1);
135
+ vi.advanceTimersByTime(100);
136
+ ctx1._factory!.component!.handleInput!("x");
137
+ await ctx1._factory!.settle;
138
+ const ctx2 = makeCtx();
139
+ await pi.handlers.session_start({}, ctx2);
140
+ vi.advanceTimersByTime(100);
141
+ expect(ctx2.ui.custom).not.toHaveBeenCalled();
94
142
  });
95
143
 
96
- it("clears the header automatically after 6 seconds", async () => {
144
+ it("skips welcome when session already has assistant activity (e.g. /reload mid-session)", async () => {
145
+ const { pi } = await install();
146
+ const ctx = makeCtx({
147
+ sessionManager: {
148
+ getBranch: () => [
149
+ { type: "message", message: { role: "assistant" } },
150
+ ],
151
+ getEntries: () => [],
152
+ },
153
+ });
154
+ await pi.handlers.session_start({}, ctx);
155
+ vi.advanceTimersByTime(100);
156
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it("auto-dismisses after 6 seconds via done(undefined)", async () => {
97
160
  const { pi } = await install();
98
161
  const ctx = makeCtx();
99
- await pi.handlers.session_start({ reason: "startup" }, ctx);
162
+ await pi.handlers.session_start({}, ctx);
163
+ vi.advanceTimersByTime(100);
164
+ const settle = ctx._factory!.settle;
100
165
  vi.advanceTimersByTime(6_000);
101
- const calls = ctx.ui.setHeader.mock.calls;
102
- expect(calls.length).toBe(2);
103
- expect(calls[1][0]).toBeUndefined();
166
+ await expect(settle).resolves.toBeUndefined();
104
167
  });
105
168
 
106
- it("clears the header on the first terminal input keystroke", async () => {
169
+ it("dismisses on first handleInput keystroke", async () => {
107
170
  const { pi } = await install();
108
171
  const ctx = makeCtx();
109
- await pi.handlers.session_start({ reason: "startup" }, ctx);
110
- const listener = ctx._terminalListener;
111
- expect(listener).not.toBeNull();
112
- const result = listener!("a");
113
- expect(result).toBeUndefined();
114
- expect(ctx.ui.setHeader).toHaveBeenLastCalledWith(undefined);
172
+ await pi.handlers.session_start({}, ctx);
173
+ vi.advanceTimersByTime(100);
174
+ const settle = ctx._factory!.settle;
175
+ ctx._factory!.component!.handleInput!("x");
176
+ await expect(settle).resolves.toBeUndefined();
115
177
  });
116
178
 
117
- it("clears the header on before_agent_start", async () => {
179
+ it("dismisses on before_agent_start (external dismiss after mount)", async () => {
118
180
  const { pi } = await install();
119
181
  const ctx = makeCtx();
120
- await pi.handlers.session_start({ reason: "startup" }, ctx);
182
+ await pi.handlers.session_start({}, ctx);
183
+ vi.advanceTimersByTime(100);
184
+ const settle = ctx._factory!.settle;
121
185
  await pi.handlers.before_agent_start({ prompt: "hi" }, ctx);
122
- expect(ctx.ui.setHeader).toHaveBeenLastCalledWith(undefined);
186
+ await expect(settle).resolves.toBeUndefined();
123
187
  });
124
188
 
125
- it("dismiss is idempotent second trigger does not double-clear", async () => {
189
+ it("before_agent_start fired during the 100ms mount-delay cancels the overlay entirely", async () => {
126
190
  const { pi } = await install();
127
191
  const ctx = makeCtx();
128
- await pi.handlers.session_start({ reason: "startup" }, ctx);
129
- await pi.handlers.before_agent_start({ prompt: "hi" }, ctx);
130
- const before = ctx.ui.setHeader.mock.calls.length;
192
+ await pi.handlers.session_start({}, ctx);
193
+ await pi.handlers.before_agent_start({ prompt: "fast" }, ctx);
194
+ vi.advanceTimersByTime(200);
195
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("dismiss is idempotent — second trigger does not throw", async () => {
199
+ const { pi } = await install();
200
+ const ctx = makeCtx();
201
+ await pi.handlers.session_start({}, ctx);
202
+ vi.advanceTimersByTime(100);
203
+ ctx._factory!.component!.handleInput!("a");
204
+ await ctx._factory!.settle;
205
+ expect(() => ctx._factory!.component!.handleInput!("b")).not.toThrow();
131
206
  vi.advanceTimersByTime(6_000);
132
- expect(ctx.ui.setHeader.mock.calls.length).toBe(before);
133
207
  });
134
208
 
135
- it("renders header with all stats and dismiss hint visible", async () => {
209
+ it("renders all stats and the dismiss hint in the overlay component", async () => {
136
210
  const { pi } = await install();
137
211
  const ctx = makeCtx();
138
- await pi.handlers.session_start({ reason: "startup" }, ctx);
139
- const factory = ctx.ui.setHeader.mock.calls[0][0] as (
140
- tui: unknown,
141
- theme: unknown,
142
- ) => { render: (w: number) => string[] };
143
- const lines = factory({}, {}).render(120);
212
+ await pi.handlers.session_start({}, ctx);
213
+ vi.advanceTimersByTime(100);
214
+ const lines = ctx._factory!.component!.render(120);
144
215
  const joined = lines.join("\n");
145
216
  expect(joined).toContain("ASTRO PI");
146
217
  expect(joined).toContain("anthropic/claude-sonnet-4-6");
@@ -156,13 +227,12 @@ describe("astro-welcome extension", () => {
156
227
  getEntries: () => [
157
228
  { type: "custom", customType: "caveman-mode", data: { level: "ultra" } },
158
229
  ],
230
+ getBranch: () => [],
159
231
  },
160
232
  });
161
- await pi.handlers.session_start({ reason: "startup" }, ctx);
162
- const factory = ctx.ui.setHeader.mock.calls[0][0] as (
163
- tui: unknown,
164
- theme: unknown,
165
- ) => { render: (w: number) => string[] };
166
- expect(factory({}, {}).render(120).join("\n")).toContain("caveman: ultra");
233
+ await pi.handlers.session_start({}, ctx);
234
+ vi.advanceTimersByTime(100);
235
+ const lines = ctx._factory!.component!.render(120);
236
+ expect(lines.join("\n")).toContain("caveman: ultra");
167
237
  });
168
238
  });
@@ -1,11 +1,17 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import { composeWelcome, type WelcomeStats } from "./compose.ts";
3
+ import { ART_HEIGHT, ART_WIDTH } from "./art.ts";
4
+ import { readPackageVersion } from "./version.ts";
3
5
 
4
6
  const DISMISS_AFTER_MS = 6_000;
7
+ const MOUNT_DELAY_MS = 100;
8
+ const OVERLAY_WIDTH = Math.max(ART_WIDTH + 30, 70);
9
+ const OVERLAY_HEIGHT = ART_HEIGHT + 8;
5
10
 
6
11
  function readCavemanLevel(ctx: ExtensionContext): string | undefined {
7
- for (let i = ctx.sessionManager.getEntries().length - 1; i >= 0; i--) {
8
- const entry = ctx.sessionManager.getEntries()[i];
12
+ const entries = ctx.sessionManager.getEntries();
13
+ for (let i = entries.length - 1; i >= 0; i--) {
14
+ const entry = entries[i];
9
15
  if (entry.type === "custom" && entry.customType === "caveman-mode") {
10
16
  const data = entry.data as { level?: string } | undefined;
11
17
  return data?.level ?? undefined;
@@ -14,74 +20,106 @@ function readCavemanLevel(ctx: ExtensionContext): string | undefined {
14
20
  return undefined;
15
21
  }
16
22
 
17
- function modelDisplayShort(modelId: string | undefined): string | undefined {
18
- if (!modelId) return undefined;
19
- const trimmed = modelId.trim();
20
- if (trimmed.length === 0) return undefined;
21
- return trimmed;
23
+ function hasSessionActivity(ctx: ExtensionContext): boolean {
24
+ for (const entry of ctx.sessionManager.getBranch()) {
25
+ if (entry.type === "message" && entry.message.role === "assistant") return true;
26
+ }
27
+ return false;
22
28
  }
23
29
 
24
30
  export default function astroWelcomeExtension(pi: ExtensionAPI): void {
25
- let dismissed = false;
26
- let timer: ReturnType<typeof setTimeout> | null = null;
27
- let unsubscribeInput: (() => void) | null = null;
28
- let activeCtx: ExtensionContext | null = null;
29
-
30
- function dismiss(): void {
31
- if (dismissed) return;
32
- dismissed = true;
33
- if (timer !== null) {
34
- clearTimeout(timer);
35
- timer = null;
36
- }
37
- if (unsubscribeInput !== null) {
38
- try {
39
- unsubscribeInput();
40
- } catch {
41
- /* ignore unsubscribe errors */
42
- }
43
- unsubscribeInput = null;
44
- }
45
- try {
46
- activeCtx?.ui.setHeader(undefined);
47
- } catch {
48
- /* ignore header clear errors */
49
- }
50
- activeCtx = null;
51
- }
31
+ let shownThisProcess = false;
32
+ let externalDismiss: (() => void) | null = null;
33
+ let dismissedBeforeMount = false;
34
+ let pendingMount: ReturnType<typeof setTimeout> | null = null;
52
35
 
53
36
  pi.on("session_start", async (_event, ctx) => {
37
+ if (shownThisProcess) return;
54
38
  if (!ctx.hasUI) return;
55
- dismissed = false;
56
- activeCtx = ctx;
57
- const seed = Date.now() & 0xffff_ffff;
39
+ if (hasSessionActivity(ctx)) return;
40
+ shownThisProcess = true;
58
41
 
42
+ const seed = Date.now() & 0xffff_ffff;
43
+ let pkgVersion: string | undefined;
44
+ try {
45
+ pkgVersion = readPackageVersion();
46
+ } catch {
47
+ pkgVersion = undefined;
48
+ }
59
49
  const stats: WelcomeStats = {
60
- modelDisplay: modelDisplayShort(ctx.model?.id),
50
+ modelDisplay: ctx.model?.id,
61
51
  contextWindow: ctx.model?.contextWindow,
62
52
  extensionCount: pi.getAllTools().length,
63
53
  cavemanLevel: readCavemanLevel(ctx),
54
+ pkgVersion,
64
55
  };
65
56
 
66
- ctx.ui.setHeader((_tui, _theme) => ({
67
- invalidate() {},
68
- render(width: number): string[] {
69
- return composeWelcome(width, seed, stats);
70
- },
71
- }));
57
+ dismissedBeforeMount = false;
72
58
 
73
- timer = setTimeout(dismiss, DISMISS_AFTER_MS);
74
- try {
75
- unsubscribeInput = ctx.ui.onTerminalInput(() => {
76
- dismiss();
77
- return undefined;
78
- });
79
- } catch {
80
- /* terminal input listener not supported in this mode */
81
- }
59
+ pendingMount = setTimeout(() => {
60
+ pendingMount = null;
61
+ if (dismissedBeforeMount) return;
62
+
63
+ let dismissed = false;
64
+ let timer: ReturnType<typeof setTimeout> | null = null;
65
+
66
+ ctx.ui
67
+ .custom(
68
+ (tui, _theme, _kb, done) => {
69
+ const dismiss = (): void => {
70
+ if (dismissed) return;
71
+ dismissed = true;
72
+ if (timer !== null) {
73
+ clearTimeout(timer);
74
+ timer = null;
75
+ }
76
+ externalDismiss = null;
77
+ done(undefined);
78
+ };
79
+
80
+ timer = setTimeout(dismiss, DISMISS_AFTER_MS);
81
+ externalDismiss = dismiss;
82
+
83
+ return {
84
+ invalidate(): void {
85
+ tui.requestRender();
86
+ },
87
+ render(width: number): string[] {
88
+ return composeWelcome(width, seed, stats);
89
+ },
90
+ handleInput(): void {
91
+ dismiss();
92
+ },
93
+ dispose(): void {
94
+ if (timer !== null) {
95
+ clearTimeout(timer);
96
+ timer = null;
97
+ }
98
+ externalDismiss = null;
99
+ },
100
+ };
101
+ },
102
+ {
103
+ overlay: true,
104
+ overlayOptions: {
105
+ anchor: "center",
106
+ width: OVERLAY_WIDTH,
107
+ maxHeight: OVERLAY_HEIGHT,
108
+ },
109
+ },
110
+ )
111
+ .catch(() => {
112
+ /* overlay close errors are not fatal */
113
+ });
114
+ }, MOUNT_DELAY_MS);
82
115
  });
83
116
 
84
117
  pi.on("before_agent_start", async () => {
85
- dismiss();
118
+ if (pendingMount !== null) {
119
+ clearTimeout(pendingMount);
120
+ pendingMount = null;
121
+ dismissedBeforeMount = true;
122
+ }
123
+ externalDismiss?.();
86
124
  });
87
125
  }
@@ -0,0 +1,12 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
6
+ const packageJsonPath = path.resolve(extensionDir, "..", "..", "package.json");
7
+
8
+ export function readPackageVersion(): string | undefined {
9
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
10
+ const pkg = JSON.parse(raw) as { version?: unknown };
11
+ return typeof pkg.version === "string" ? pkg.version : undefined;
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"