@astrofoundry/pi-astro 0.14.0 → 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,82 +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 () => {
82
- const { pi } = await install();
83
- 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);
87
- });
88
-
89
- it("does nothing when reason is not 'startup' (e.g. reload, resume)", async () => {
111
+ it("session_start opens a centered overlay via ctx.ui.custom", async () => {
90
112
  const { pi } = await install();
91
113
  const ctx = makeCtx();
92
- await pi.handlers.session_start({ reason: "reload" }, ctx);
93
- expect(ctx.ui.setHeader).not.toHaveBeenCalled();
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");
94
119
  });
95
120
 
96
121
  it("does nothing when ctx.hasUI is false", async () => {
97
122
  const { pi } = await install();
98
123
  const ctx = makeCtx({ hasUI: false });
99
- await pi.handlers.session_start({ reason: "startup" }, ctx);
100
- expect(ctx.ui.setHeader).not.toHaveBeenCalled();
124
+ await pi.handlers.session_start({}, ctx);
125
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
101
126
  });
102
127
 
103
- it("clears the header automatically after 6 seconds", async () => {
128
+ it("auto-dismisses after 6 seconds via done(undefined)", async () => {
104
129
  const { pi } = await install();
105
130
  const ctx = makeCtx();
106
- await pi.handlers.session_start({ reason: "startup" }, ctx);
131
+ await pi.handlers.session_start({}, ctx);
132
+ const settle = ctx._factory!.settle;
107
133
  vi.advanceTimersByTime(6_000);
108
- const calls = ctx.ui.setHeader.mock.calls;
109
- expect(calls.length).toBe(2);
110
- expect(calls[1][0]).toBeUndefined();
134
+ await expect(settle).resolves.toBeUndefined();
111
135
  });
112
136
 
113
- it("clears the header on the first terminal input keystroke", async () => {
137
+ it("dismisses on first handleInput keystroke", async () => {
114
138
  const { pi } = await install();
115
139
  const ctx = makeCtx();
116
- await pi.handlers.session_start({ reason: "startup" }, ctx);
117
- const listener = ctx._terminalListener;
118
- expect(listener).not.toBeNull();
119
- const result = listener!("a");
120
- expect(result).toBeUndefined();
121
- 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();
122
144
  });
123
145
 
124
- it("clears the header on before_agent_start", async () => {
146
+ it("dismisses on before_agent_start (external dismiss)", async () => {
125
147
  const { pi } = await install();
126
148
  const ctx = makeCtx();
127
- await pi.handlers.session_start({ reason: "startup" }, ctx);
149
+ await pi.handlers.session_start({}, ctx);
150
+ const settle = ctx._factory!.settle;
128
151
  await pi.handlers.before_agent_start({ prompt: "hi" }, ctx);
129
- expect(ctx.ui.setHeader).toHaveBeenLastCalledWith(undefined);
152
+ await expect(settle).resolves.toBeUndefined();
130
153
  });
131
154
 
132
- it("dismiss is idempotent — second trigger does not double-clear", async () => {
155
+ it("dismiss is idempotent — second trigger does not throw", async () => {
133
156
  const { pi } = await install();
134
157
  const ctx = makeCtx();
135
- await pi.handlers.session_start({ reason: "startup" }, ctx);
136
- await pi.handlers.before_agent_start({ prompt: "hi" }, ctx);
137
- 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();
138
162
  vi.advanceTimersByTime(6_000);
139
- expect(ctx.ui.setHeader.mock.calls.length).toBe(before);
140
163
  });
141
164
 
142
- 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 () => {
143
166
  const { pi } = await install();
144
167
  const ctx = makeCtx();
145
- await pi.handlers.session_start({ reason: "startup" }, ctx);
146
- const factory = ctx.ui.setHeader.mock.calls[0][0] as (
147
- tui: unknown,
148
- theme: unknown,
149
- ) => { render: (w: number) => string[] };
150
- const lines = factory({}, {}).render(120);
168
+ await pi.handlers.session_start({}, ctx);
169
+ const lines = ctx._factory!.component!.render(120);
151
170
  const joined = lines.join("\n");
152
171
  expect(joined).toContain("ASTRO PI");
153
172
  expect(joined).toContain("anthropic/claude-sonnet-4-6");
@@ -165,11 +184,8 @@ describe("astro-welcome extension", () => {
165
184
  ],
166
185
  },
167
186
  });
168
- await pi.handlers.session_start({ reason: "startup" }, ctx);
169
- const factory = ctx.ui.setHeader.mock.calls[0][0] as (
170
- tui: unknown,
171
- theme: unknown,
172
- ) => { render: (w: number) => string[] };
173
- 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");
174
190
  });
175
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,75 +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
- pi.on("session_start", async (event, ctx) => {
54
- if (event.reason !== "startup") return;
24
+ pi.on("session_start", async (_event, ctx) => {
55
25
  if (!ctx.hasUI) return;
56
- dismissed = false;
57
- activeCtx = ctx;
58
- const seed = Date.now() & 0xffff_ffff;
59
26
 
27
+ const seed = Date.now() & 0xffff_ffff;
60
28
  const stats: WelcomeStats = {
61
- modelDisplay: modelDisplayShort(ctx.model?.id),
29
+ modelDisplay: ctx.model?.id,
62
30
  contextWindow: ctx.model?.contextWindow,
63
31
  extensionCount: pi.getAllTools().length,
64
32
  cavemanLevel: readCavemanLevel(ctx),
65
33
  };
66
34
 
67
- ctx.ui.setHeader((_tui, _theme) => ({
68
- invalidate() {},
69
- render(width: number): string[] {
70
- return composeWelcome(width, seed, stats);
71
- },
72
- }));
35
+ let dismissed = false;
36
+ let timer: ReturnType<typeof setTimeout> | null = null;
73
37
 
74
- timer = setTimeout(dismiss, DISMISS_AFTER_MS);
75
- try {
76
- unsubscribeInput = ctx.ui.onTerminalInput(() => {
77
- dismiss();
78
- 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 */
79
85
  });
80
- } catch {
81
- /* terminal input listener not supported in this mode */
82
- }
83
86
  });
84
87
 
85
88
  pi.on("before_agent_start", async () => {
86
- dismiss();
89
+ externalDismiss?.();
87
90
  });
88
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.14.0",
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"