@astrofoundry/pi-astro 0.14.1 → 0.14.2

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.
@@ -25,33 +25,63 @@ interface FakeCtx {
25
25
  model: { id: string; contextWindow: number } | undefined;
26
26
  sessionManager: { getEntries: () => 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
78
  sessionManager: { getEntries: () => [] },
46
- ui: {
47
- setHeader: vi.fn(),
48
- onTerminalInput,
49
- },
50
- get _terminalListener() {
51
- return state.listener;
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,71 @@ 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", 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).toHaveBeenCalledTimes(1);
116
+ const opts = ctx._factory!.options as { overlay: boolean; overlayOptions: { anchor: string } };
117
+ expect(opts.overlay).toBe(true);
118
+ expect(opts.overlayOptions.anchor).toBe("center");
87
119
  });
88
120
 
89
121
  it("does nothing when ctx.hasUI is false", async () => {
90
122
  const { pi } = await install();
91
123
  const ctx = makeCtx({ hasUI: false });
92
- await pi.handlers.session_start({ reason: "startup" }, ctx);
93
- expect(ctx.ui.setHeader).not.toHaveBeenCalled();
124
+ await pi.handlers.session_start({}, ctx);
125
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
94
126
  });
95
127
 
96
- it("clears the header automatically after 6 seconds", async () => {
128
+ it("auto-dismisses after 6 seconds via done(undefined)", async () => {
97
129
  const { pi } = await install();
98
130
  const ctx = makeCtx();
99
- await pi.handlers.session_start({ reason: "startup" }, ctx);
131
+ await pi.handlers.session_start({}, ctx);
132
+ const settle = ctx._factory!.settle;
100
133
  vi.advanceTimersByTime(6_000);
101
- const calls = ctx.ui.setHeader.mock.calls;
102
- expect(calls.length).toBe(2);
103
- expect(calls[1][0]).toBeUndefined();
134
+ await expect(settle).resolves.toBeUndefined();
104
135
  });
105
136
 
106
- it("clears the header on the first terminal input keystroke", async () => {
137
+ it("dismisses on first handleInput keystroke", async () => {
107
138
  const { pi } = await install();
108
139
  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);
140
+ await pi.handlers.session_start({}, ctx);
141
+ const settle = ctx._factory!.settle;
142
+ ctx._factory!.component!.handleInput!("x");
143
+ await expect(settle).resolves.toBeUndefined();
115
144
  });
116
145
 
117
- it("clears the header on before_agent_start", async () => {
146
+ it("dismisses on before_agent_start (external dismiss)", async () => {
118
147
  const { pi } = await install();
119
148
  const ctx = makeCtx();
120
- await pi.handlers.session_start({ reason: "startup" }, ctx);
149
+ await pi.handlers.session_start({}, ctx);
150
+ const settle = ctx._factory!.settle;
121
151
  await pi.handlers.before_agent_start({ prompt: "hi" }, ctx);
122
- expect(ctx.ui.setHeader).toHaveBeenLastCalledWith(undefined);
152
+ await expect(settle).resolves.toBeUndefined();
123
153
  });
124
154
 
125
- it("dismiss is idempotent — second trigger does not double-clear", async () => {
155
+ it("dismiss is idempotent — second trigger does not throw", async () => {
126
156
  const { pi } = await install();
127
157
  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;
158
+ await pi.handlers.session_start({}, ctx);
159
+ ctx._factory!.component!.handleInput!("a");
160
+ await ctx._factory!.settle;
161
+ expect(() => ctx._factory!.component!.handleInput!("b")).not.toThrow();
131
162
  vi.advanceTimersByTime(6_000);
132
- expect(ctx.ui.setHeader.mock.calls.length).toBe(before);
133
163
  });
134
164
 
135
- it("renders header with all stats and dismiss hint visible", async () => {
165
+ it("renders all stats and the dismiss hint in the overlay component", async () => {
136
166
  const { pi } = await install();
137
167
  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);
168
+ await pi.handlers.session_start({}, ctx);
169
+ const lines = ctx._factory!.component!.render(120);
144
170
  const joined = lines.join("\n");
145
171
  expect(joined).toContain("ASTRO PI");
146
172
  expect(joined).toContain("anthropic/claude-sonnet-4-6");
@@ -158,11 +184,8 @@ describe("astro-welcome extension", () => {
158
184
  ],
159
185
  },
160
186
  });
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");
187
+ await pi.handlers.session_start({}, ctx);
188
+ const lines = ctx._factory!.component!.render(120);
189
+ expect(lines.join("\n")).toContain("caveman: ultra");
167
190
  });
168
191
  });
@@ -1,11 +1,15 @@
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";
3
4
 
4
5
  const DISMISS_AFTER_MS = 6_000;
6
+ const OVERLAY_WIDTH = Math.max(ART_WIDTH + 30, 70);
7
+ const OVERLAY_HEIGHT = ART_HEIGHT + 7;
5
8
 
6
9
  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];
10
+ const entries = ctx.sessionManager.getEntries();
11
+ for (let i = entries.length - 1; i >= 0; i--) {
12
+ const entry = entries[i];
9
13
  if (entry.type === "custom" && entry.customType === "caveman-mode") {
10
14
  const data = entry.data as { level?: string } | undefined;
11
15
  return data?.level ?? undefined;
@@ -14,74 +18,74 @@ function readCavemanLevel(ctx: ExtensionContext): string | undefined {
14
18
  return undefined;
15
19
  }
16
20
 
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;
22
- }
23
-
24
21
  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
- }
22
+ let externalDismiss: (() => void) | null = null;
52
23
 
53
24
  pi.on("session_start", async (_event, ctx) => {
54
25
  if (!ctx.hasUI) return;
55
- dismissed = false;
56
- activeCtx = ctx;
57
- const seed = Date.now() & 0xffff_ffff;
58
26
 
27
+ const seed = Date.now() & 0xffff_ffff;
59
28
  const stats: WelcomeStats = {
60
- modelDisplay: modelDisplayShort(ctx.model?.id),
29
+ modelDisplay: ctx.model?.id,
61
30
  contextWindow: ctx.model?.contextWindow,
62
31
  extensionCount: pi.getAllTools().length,
63
32
  cavemanLevel: readCavemanLevel(ctx),
64
33
  };
65
34
 
66
- ctx.ui.setHeader((_tui, _theme) => ({
67
- invalidate() {},
68
- render(width: number): string[] {
69
- return composeWelcome(width, seed, stats);
70
- },
71
- }));
35
+ let dismissed = false;
36
+ let timer: ReturnType<typeof setTimeout> | null = null;
72
37
 
73
- timer = setTimeout(dismiss, DISMISS_AFTER_MS);
74
- try {
75
- unsubscribeInput = ctx.ui.onTerminalInput(() => {
76
- dismiss();
77
- return undefined;
38
+ ctx.ui
39
+ .custom(
40
+ (tui, _theme, _kb, done) => {
41
+ const dismiss = (): void => {
42
+ if (dismissed) return;
43
+ dismissed = true;
44
+ if (timer !== null) {
45
+ clearTimeout(timer);
46
+ timer = null;
47
+ }
48
+ externalDismiss = null;
49
+ done(undefined);
50
+ };
51
+
52
+ timer = setTimeout(dismiss, DISMISS_AFTER_MS);
53
+ externalDismiss = dismiss;
54
+
55
+ return {
56
+ invalidate(): void {
57
+ tui.requestRender();
58
+ },
59
+ render(width: number): string[] {
60
+ return composeWelcome(width, seed, stats);
61
+ },
62
+ handleInput(): void {
63
+ dismiss();
64
+ },
65
+ dispose(): void {
66
+ if (timer !== null) {
67
+ clearTimeout(timer);
68
+ timer = null;
69
+ }
70
+ externalDismiss = null;
71
+ },
72
+ };
73
+ },
74
+ {
75
+ overlay: true,
76
+ overlayOptions: {
77
+ anchor: "center",
78
+ width: OVERLAY_WIDTH,
79
+ maxHeight: OVERLAY_HEIGHT,
80
+ },
81
+ },
82
+ )
83
+ .catch(() => {
84
+ /* overlay close errors are not fatal */
78
85
  });
79
- } catch {
80
- /* terminal input listener not supported in this mode */
81
- }
82
86
  });
83
87
 
84
88
  pi.on("before_agent_start", async () => {
85
- dismiss();
89
+ externalDismiss?.();
86
90
  });
87
91
  }
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.2",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"