@astrofoundry/pi-astro 0.13.2 → 0.14.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.
- package/README.md +1 -0
- package/extensions/astro-welcome/README.md +46 -0
- package/extensions/astro-welcome/art.test.ts +35 -0
- package/extensions/astro-welcome/art.ts +125 -0
- package/extensions/astro-welcome/colors.ts +33 -0
- package/extensions/astro-welcome/compose.test.ts +56 -0
- package/extensions/astro-welcome/compose.ts +134 -0
- package/extensions/astro-welcome/index.test.ts +168 -0
- package/extensions/astro-welcome/index.ts +87 -0
- package/extensions/astro-welcome/messages.test.ts +19 -0
- package/extensions/astro-welcome/messages.ts +19 -0
- package/extensions/astro-welcome/stars.ts +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,7 @@ pi # launch; confirm [Extensions] lists astro-agents, grimoire, a
|
|
|
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
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
|
+
- `astro-welcome` — pixel-art astronaut welcome screen (24-bit colour, twinkling starfield, rotating funny tagline, live stats) rendered on session start; auto-dismisses after 6 s, on first keystroke, or on first prompt submit. See [extensions/astro-welcome/README.md](extensions/astro-welcome/README.md).
|
|
70
71
|
- `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
72
|
|
|
72
73
|
**Bundled subagents** (callable via `astro_agent`):
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# astro-welcome
|
|
2
|
+
|
|
3
|
+
A short astro-themed welcome screen rendered as the pi startup header on every fresh session.
|
|
4
|
+
|
|
5
|
+
- Pixel-art astronaut (Unicode halfblocks + 24-bit colour) — two variants rotated per session
|
|
6
|
+
- Twinkling starfield in the background, deterministically seeded from session-start time
|
|
7
|
+
- Funny tagline rotated from a pool of ~12
|
|
8
|
+
- Live stats: model, context-window total, extension count, current caveman level
|
|
9
|
+
- Auto-dismisses after 6 seconds, on the first keystroke, or when you submit your first prompt — whichever comes first
|
|
10
|
+
|
|
11
|
+
Ships as part of [`@astrofoundry/pi-astro`](../../README.md). No extra install, no command — activates automatically on `session_start` with reason `startup`. Reload and resume don't trigger the splash.
|
|
12
|
+
|
|
13
|
+
## Behaviour
|
|
14
|
+
|
|
15
|
+
| Event | Action |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `session_start` (`startup`) | Render via `ctx.ui.setHeader(…)`, arm 6 s timeout, register raw-input listener |
|
|
18
|
+
| 6 s elapsed | Auto-dismiss |
|
|
19
|
+
| any keystroke | Dismiss (keystroke passes through to the editor — not consumed) |
|
|
20
|
+
| `before_agent_start` | Dismiss (covers the pasted-prompt-then-Enter case) |
|
|
21
|
+
| dismissed once | Idempotent — further triggers are no-ops |
|
|
22
|
+
|
|
23
|
+
## Layout
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
★ ASTRO PI ★
|
|
27
|
+
|
|
28
|
+
[pixel-art astronaut]
|
|
29
|
+
|
|
30
|
+
Welcome aboard, Astronaute. <funny tagline>
|
|
31
|
+
|
|
32
|
+
◈ <model> ◫ <ctx window> ⚙ <N> extensions ✦ caveman: <level>
|
|
33
|
+
|
|
34
|
+
Press any key to launch · auto-dismiss in 6s
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The astronaut and starfield use raw 24-bit ANSI colour escapes so the look is the same across iTerm2 / Ghostty / WezTerm / Kitty / Alacritty.
|
|
38
|
+
|
|
39
|
+
## Customising
|
|
40
|
+
|
|
41
|
+
There's no config surface at the moment. To tweak:
|
|
42
|
+
|
|
43
|
+
- **Taglines**: edit `messages.ts` — array `MESSAGES`. Add or remove freely.
|
|
44
|
+
- **Astronaut variants**: edit `art.ts` — `ASTRONAUT_FLOATING` and `ASTRONAUT_FLAG` are pixel grids where each character maps to a colour key (`S`, `X`, `D`, `M`, `W`, `Y`, `G`, `T`, `E`, `F`, `O`, `R`). Each grid is rendered with halfblocks, doubling vertical resolution.
|
|
45
|
+
- **Palette**: `colors.ts` exports `PALETTE` — every colour is a `[R, G, B]` triple. Tweak in place.
|
|
46
|
+
- **Dismiss timeout**: `DISMISS_AFTER_MS` in `index.ts`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ART_HEIGHT, ART_WIDTH, pickVariant, renderArt, VARIANTS } from "./art.ts";
|
|
3
|
+
|
|
4
|
+
describe("art", () => {
|
|
5
|
+
it("exposes ART_WIDTH and ART_HEIGHT consistent with the variants", () => {
|
|
6
|
+
expect(ART_WIDTH).toBeGreaterThan(0);
|
|
7
|
+
expect(ART_HEIGHT).toBeGreaterThan(0);
|
|
8
|
+
for (const v of VARIANTS) {
|
|
9
|
+
expect(v[0].length).toBe(ART_WIDTH);
|
|
10
|
+
expect(Math.ceil(v.length / 2)).toBe(ART_HEIGHT);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("pickVariant is deterministic for the same seed", () => {
|
|
15
|
+
expect(pickVariant(0)).toBe(pickVariant(0));
|
|
16
|
+
expect(pickVariant(7)).toBe(pickVariant(7));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("pickVariant handles negative seeds without throwing", () => {
|
|
20
|
+
expect(() => pickVariant(-100)).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renderArt produces ART_HEIGHT rows", () => {
|
|
24
|
+
const lines = renderArt(VARIANTS[0]);
|
|
25
|
+
expect(lines).toHaveLength(ART_HEIGHT);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renderArt embeds 24-bit ANSI fg+bg colours and the upper-half block glyph", () => {
|
|
29
|
+
const lines = renderArt(VARIANTS[0]);
|
|
30
|
+
const joined = lines.join("\n");
|
|
31
|
+
expect(joined).toContain("[38;2;");
|
|
32
|
+
expect(joined).toContain(";48;2;");
|
|
33
|
+
expect(joined).toContain("▀");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { fgBg, PALETTE, type RGB } from "./colors.ts";
|
|
2
|
+
|
|
3
|
+
const _ = "·";
|
|
4
|
+
const S = "S";
|
|
5
|
+
const X = "X";
|
|
6
|
+
const A = "A";
|
|
7
|
+
const Y = "Y";
|
|
8
|
+
const W = "W";
|
|
9
|
+
const M = "M";
|
|
10
|
+
const D = "D";
|
|
11
|
+
const G = "G";
|
|
12
|
+
const T = "T";
|
|
13
|
+
const E = "E";
|
|
14
|
+
const F = "F";
|
|
15
|
+
const O = "O";
|
|
16
|
+
const R = "R";
|
|
17
|
+
|
|
18
|
+
type PaletteKey = typeof _ | typeof S | typeof X | typeof A | typeof Y | typeof W | typeof M | typeof D | typeof G | typeof T | typeof E | typeof F | typeof O | typeof R;
|
|
19
|
+
|
|
20
|
+
const PIXEL_COLOR: Record<PaletteKey, RGB | null> = {
|
|
21
|
+
[_]: null,
|
|
22
|
+
[S]: PALETTE.suitLight,
|
|
23
|
+
[X]: PALETTE.suitMid,
|
|
24
|
+
[A]: PALETTE.suitShadow,
|
|
25
|
+
[Y]: PALETTE.suitTrim,
|
|
26
|
+
[W]: PALETTE.visorLow,
|
|
27
|
+
[M]: PALETTE.visorMid,
|
|
28
|
+
[D]: PALETTE.visorTop,
|
|
29
|
+
[G]: PALETTE.backpack,
|
|
30
|
+
[T]: PALETTE.earthBlue,
|
|
31
|
+
[E]: PALETTE.earthGreen,
|
|
32
|
+
[F]: PALETTE.flameYellow,
|
|
33
|
+
[O]: PALETTE.flameOrange,
|
|
34
|
+
[R]: PALETTE.flameRed,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ASTRONAUT_FLOATING: readonly string[] = [
|
|
38
|
+
"··········SSSSSSSSSS··········",
|
|
39
|
+
"·······SSSSXXXXXXXXSSSS·······",
|
|
40
|
+
"·····SSXXMMMMMMMMMMMMXXSS·····",
|
|
41
|
+
"····SXMMDDDDDDDDDDDDDDMMSS····",
|
|
42
|
+
"···SXMDDDDWWDDDDDDWWDDDDMMS···",
|
|
43
|
+
"···SXMDDWWWWDDDDDDWWWWDDMMS···",
|
|
44
|
+
"···SXMDDDDDDDDDDDDDDDDDDMMS···",
|
|
45
|
+
"···SXMDDDDDDDDWWDDDDDDDDMMS···",
|
|
46
|
+
"····SXMMDDDDDDDDDDDDDDMMSS····",
|
|
47
|
+
"·····SSXXMMMMMMMMMMMMXXSS·····",
|
|
48
|
+
"·······SSXXXXXXXXXXXXSS·······",
|
|
49
|
+
"··········SXAAAAAAXSS·········",
|
|
50
|
+
"·······SSSSSSSSSSSSSSSS·······",
|
|
51
|
+
"······SSSSGGGGSSSSGGGGSSS·····",
|
|
52
|
+
"·····SSSSGGGGSSAAYAASSGGSSS···",
|
|
53
|
+
"·····SSGGGGSSSAYYYAASSGGGGS···",
|
|
54
|
+
"·····SSGGGSSSSAYAYASSSSGGGS···",
|
|
55
|
+
"·····SSGGGSSSSAAAASSSSSGGGS···",
|
|
56
|
+
"·····SSGGGSSSSSSSSSSSSGGGS····",
|
|
57
|
+
"······SSSSSSSSSSSSSSSSSS······",
|
|
58
|
+
"········SSAAAAAASSSSSSAAAA····",
|
|
59
|
+
"········SSSAAAASSSSAAAAS······",
|
|
60
|
+
"········SXXSSSSSSSXXXSS·······",
|
|
61
|
+
"·········SSSS·····SSSS········",
|
|
62
|
+
"··········SSS······SSS········",
|
|
63
|
+
"··········SS········SS········",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const ASTRONAUT_FLAG: readonly string[] = [
|
|
67
|
+
"··········SSSSSSSSSS··········",
|
|
68
|
+
"·······SSSSXXXXXXXXSSSS·······",
|
|
69
|
+
"·····SSXXMMMMMMMMMMMMXXSS·····",
|
|
70
|
+
"····SXMMDDDDDDDDDDDDDDMMSS····",
|
|
71
|
+
"···SXMDDDDWWDDDDDDWWDDDDMMS···",
|
|
72
|
+
"···SXMDDWWWWDDDDDDWWWWDDMMS···",
|
|
73
|
+
"···SXMDDDDDDDDDDDDDDDDDDMMS···",
|
|
74
|
+
"···SXMDDDDDDDDWWDDDDDDDDMMS···",
|
|
75
|
+
"····SXMMDDDDDDDDDDDDDDMMSS····",
|
|
76
|
+
"·····SSXXMMMMMMMMMMMMXXSS·····",
|
|
77
|
+
"·······SSXXXXXXXXXXXXSS·······",
|
|
78
|
+
"··········SXAAAAAAXSS·········",
|
|
79
|
+
"······YYYYYSSSSSSSSSSSSSS·····",
|
|
80
|
+
"······YYYYYSSGGGGSSSGGGGSSS···",
|
|
81
|
+
"······YYYYYSSGGGGSSSSGGGSS····",
|
|
82
|
+
"······YYYYYSSGGGSSSSSSGGGS····",
|
|
83
|
+
"······YYYYYSSGGSSSSSSSSGGS····",
|
|
84
|
+
"········SSSSSSSSSSSSSSSSSS····",
|
|
85
|
+
"········SSAAAAAASSSAAAASS·····",
|
|
86
|
+
"········SSSAAAASSSAAAAAS······",
|
|
87
|
+
"········SXXSSSSSSSXXXSS·······",
|
|
88
|
+
"·········SSSS·····SSSS········",
|
|
89
|
+
"··········SSS······SSS········",
|
|
90
|
+
"··········SS········SS········",
|
|
91
|
+
"·························E····",
|
|
92
|
+
"·························TT···",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export const VARIANTS = [ASTRONAUT_FLOATING, ASTRONAUT_FLAG] as const;
|
|
96
|
+
|
|
97
|
+
export function pickVariant(seed: number): readonly string[] {
|
|
98
|
+
const idx = Math.abs(seed) % VARIANTS.length;
|
|
99
|
+
return VARIANTS[idx];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderArt(grid: readonly string[]): string[] {
|
|
103
|
+
const rows: string[] = [];
|
|
104
|
+
for (let y = 0; y < grid.length; y += 2) {
|
|
105
|
+
const top = grid[y];
|
|
106
|
+
const bot = grid[y + 1] ?? "·".repeat(top.length);
|
|
107
|
+
let line = "";
|
|
108
|
+
for (let x = 0; x < top.length; x++) {
|
|
109
|
+
const tk = (top[x] ?? "·") as PaletteKey;
|
|
110
|
+
const bk = (bot[x] ?? "·") as PaletteKey;
|
|
111
|
+
const tc = PIXEL_COLOR[tk] ?? null;
|
|
112
|
+
const bc = PIXEL_COLOR[bk] ?? null;
|
|
113
|
+
if (!tc && !bc) {
|
|
114
|
+
line += " ";
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
line += fgBg(tc ?? PALETTE.space, bc ?? PALETTE.space);
|
|
118
|
+
}
|
|
119
|
+
rows.push(line);
|
|
120
|
+
}
|
|
121
|
+
return rows;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const ART_WIDTH = ASTRONAUT_FLOATING[0].length;
|
|
125
|
+
export const ART_HEIGHT = Math.ceil(ASTRONAUT_FLOATING.length / 2);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type RGB = readonly [number, number, number];
|
|
2
|
+
|
|
3
|
+
export function fg(rgb: RGB, text: string): string {
|
|
4
|
+
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${text}\x1b[0m`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function fgBg(top: RGB, bottom: RGB): string {
|
|
8
|
+
return `\x1b[38;2;${top[0]};${top[1]};${top[2]};48;2;${bottom[0]};${bottom[1]};${bottom[2]}m▀\x1b[0m`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const PALETTE = {
|
|
12
|
+
space: [10, 12, 30] as const,
|
|
13
|
+
starDim: [70, 90, 130] as const,
|
|
14
|
+
starBright: [180, 200, 240] as const,
|
|
15
|
+
starGold: [240, 200, 80] as const,
|
|
16
|
+
visorTop: [40, 80, 180] as const,
|
|
17
|
+
visorMid: [80, 160, 240] as const,
|
|
18
|
+
visorLow: [180, 220, 255] as const,
|
|
19
|
+
suitLight: [240, 240, 230] as const,
|
|
20
|
+
suitMid: [200, 200, 195] as const,
|
|
21
|
+
suitShadow: [120, 120, 130] as const,
|
|
22
|
+
suitTrim: [240, 200, 60] as const,
|
|
23
|
+
backpack: [80, 80, 90] as const,
|
|
24
|
+
earthBlue: [50, 110, 200] as const,
|
|
25
|
+
earthGreen: [70, 160, 100] as const,
|
|
26
|
+
flameYellow: [255, 220, 60] as const,
|
|
27
|
+
flameOrange: [255, 140, 30] as const,
|
|
28
|
+
flameRed: [220, 60, 30] as const,
|
|
29
|
+
title: [255, 210, 80] as const,
|
|
30
|
+
tagline: [200, 200, 220] as const,
|
|
31
|
+
stats: [220, 180, 100] as const,
|
|
32
|
+
hint: [110, 130, 160] as const,
|
|
33
|
+
} satisfies Record<string, RGB>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { composeWelcome, type WelcomeStats } from "./compose.ts";
|
|
3
|
+
|
|
4
|
+
const baseStats: WelcomeStats = {
|
|
5
|
+
modelDisplay: "MoonshotAI: Kimi K2.6",
|
|
6
|
+
contextWindow: 1_000_000,
|
|
7
|
+
extensionCount: 9,
|
|
8
|
+
cavemanLevel: undefined,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("composeWelcome", () => {
|
|
12
|
+
it("returns a non-empty grid of lines", () => {
|
|
13
|
+
const lines = composeWelcome(120, 1, baseStats);
|
|
14
|
+
expect(lines.length).toBeGreaterThan(8);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("includes the title text on the first line", () => {
|
|
18
|
+
const lines = composeWelcome(120, 1, baseStats);
|
|
19
|
+
expect(lines[0]).toContain("ASTRO PI");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("includes the model in the stats line", () => {
|
|
23
|
+
const lines = composeWelcome(120, 1, baseStats);
|
|
24
|
+
const joined = lines.join("\n");
|
|
25
|
+
expect(joined).toContain("MoonshotAI: Kimi K2.6");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("formats context window as 1.0M ctx", () => {
|
|
29
|
+
const lines = composeWelcome(120, 1, baseStats);
|
|
30
|
+
expect(lines.join("\n")).toContain("1.0M ctx");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("shows caveman idle when no level set", () => {
|
|
34
|
+
expect(composeWelcome(120, 1, baseStats).join("\n")).toContain("caveman: idle");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("shows caveman level when set", () => {
|
|
38
|
+
expect(
|
|
39
|
+
composeWelcome(120, 1, { ...baseStats, cavemanLevel: "ultra" }).join("\n"),
|
|
40
|
+
).toContain("caveman: ultra");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("contains the dismiss hint", () => {
|
|
44
|
+
expect(composeWelcome(120, 1, baseStats).join("\n")).toContain("Press any key");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("handles narrow widths without crashing", () => {
|
|
48
|
+
expect(() => composeWelcome(40, 1, baseStats)).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("produces deterministic output for the same seed", () => {
|
|
52
|
+
const a = composeWelcome(120, 99, baseStats);
|
|
53
|
+
const b = composeWelcome(120, 99, baseStats);
|
|
54
|
+
expect(a).toEqual(b);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
2
|
+
import { fg, PALETTE } from "./colors.ts";
|
|
3
|
+
import { ART_HEIGHT, ART_WIDTH, pickVariant, renderArt } from "./art.ts";
|
|
4
|
+
import { pickMessage } from "./messages.ts";
|
|
5
|
+
import { renderStarfield, type Mask } from "./stars.ts";
|
|
6
|
+
|
|
7
|
+
export interface WelcomeStats {
|
|
8
|
+
modelDisplay: string | undefined;
|
|
9
|
+
contextWindow: number | undefined;
|
|
10
|
+
extensionCount: number;
|
|
11
|
+
cavemanLevel: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TITLE = "★ ASTRO PI ★";
|
|
15
|
+
const HINT = "Press any key to launch · auto-dismiss in 6s";
|
|
16
|
+
|
|
17
|
+
function formatTokens(n: number | undefined): string {
|
|
18
|
+
if (n === undefined || n <= 0) return "—";
|
|
19
|
+
if (n < 1000) return `${n}`;
|
|
20
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(0)}k`;
|
|
21
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function statsLine(stats: WelcomeStats): string {
|
|
25
|
+
const parts: string[] = [];
|
|
26
|
+
if (stats.modelDisplay) parts.push(`◈ ${stats.modelDisplay}`);
|
|
27
|
+
parts.push(`◫ ${formatTokens(stats.contextWindow)} ctx`);
|
|
28
|
+
parts.push(`⚙ ${stats.extensionCount} extensions`);
|
|
29
|
+
parts.push(`✦ caveman: ${stats.cavemanLevel ?? "idle"}`);
|
|
30
|
+
return parts.join(" ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function centerInLine(line: string, width: number): { line: string; col: number } {
|
|
34
|
+
const w = visibleWidth(line);
|
|
35
|
+
if (w >= width) return { line, col: 0 };
|
|
36
|
+
const pad = Math.floor((width - w) / 2);
|
|
37
|
+
return { line: " ".repeat(pad) + line, col: pad };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class CompositeMask implements Mask {
|
|
41
|
+
public readonly rows: number;
|
|
42
|
+
public readonly cols: number;
|
|
43
|
+
private readonly grid: boolean[][];
|
|
44
|
+
|
|
45
|
+
constructor(rows: number, cols: number) {
|
|
46
|
+
this.rows = rows;
|
|
47
|
+
this.cols = cols;
|
|
48
|
+
this.grid = Array.from({ length: rows }, () => new Array<boolean>(cols).fill(false));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
occupy(row: number, col: number, length: number): void {
|
|
52
|
+
if (row < 0 || row >= this.rows) return;
|
|
53
|
+
for (let c = col; c < col + length && c < this.cols; c++) {
|
|
54
|
+
if (c >= 0) this.grid[row][c] = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
occupyArt(startRow: number, startCol: number, height: number, width: number): void {
|
|
59
|
+
for (let r = startRow; r < startRow + height; r++) {
|
|
60
|
+
this.occupy(r, startCol, width);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isOccupied(row: number, col: number): boolean {
|
|
65
|
+
if (row < 0 || row >= this.rows) return false;
|
|
66
|
+
if (col < 0 || col >= this.cols) return false;
|
|
67
|
+
return this.grid[row][col];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function composeWelcome(width: number, seed: number, stats: WelcomeStats): string[] {
|
|
72
|
+
const totalRows = ART_HEIGHT + 7;
|
|
73
|
+
const safeWidth = Math.max(40, width);
|
|
74
|
+
const artStartCol = Math.max(0, Math.floor((safeWidth - ART_WIDTH) / 2));
|
|
75
|
+
const artStartRow = 1;
|
|
76
|
+
|
|
77
|
+
const mask = new CompositeMask(totalRows, safeWidth);
|
|
78
|
+
mask.occupyArt(artStartRow, artStartCol, ART_HEIGHT, ART_WIDTH);
|
|
79
|
+
|
|
80
|
+
const titleLine = fg(PALETTE.title, TITLE);
|
|
81
|
+
const titlePlaced = centerInLine(titleLine, safeWidth);
|
|
82
|
+
mask.occupy(0, titlePlaced.col, visibleWidth(titleLine));
|
|
83
|
+
|
|
84
|
+
const taglineRaw = pickMessage(seed);
|
|
85
|
+
const taglineLine = fg(PALETTE.tagline, taglineRaw);
|
|
86
|
+
const taglineRow = artStartRow + ART_HEIGHT + 1;
|
|
87
|
+
const taglinePlaced = centerInLine(taglineLine, safeWidth);
|
|
88
|
+
mask.occupy(taglineRow, taglinePlaced.col, visibleWidth(taglineLine));
|
|
89
|
+
|
|
90
|
+
const statsRaw = statsLine(stats);
|
|
91
|
+
const statsColored = fg(PALETTE.stats, statsRaw);
|
|
92
|
+
const statsRow = taglineRow + 2;
|
|
93
|
+
const statsPlaced = centerInLine(statsColored, safeWidth);
|
|
94
|
+
mask.occupy(statsRow, statsPlaced.col, visibleWidth(statsColored));
|
|
95
|
+
|
|
96
|
+
const hintColored = fg(PALETTE.hint, HINT);
|
|
97
|
+
const hintRow = statsRow + 2;
|
|
98
|
+
const hintPlaced = centerInLine(hintColored, safeWidth);
|
|
99
|
+
mask.occupy(hintRow, hintPlaced.col, visibleWidth(hintColored));
|
|
100
|
+
|
|
101
|
+
const stars = renderStarfield(safeWidth, totalRows, seed, mask);
|
|
102
|
+
const artLines = renderArt(pickVariant(seed));
|
|
103
|
+
|
|
104
|
+
const composed: string[] = [];
|
|
105
|
+
for (let r = 0; r < totalRows; r++) {
|
|
106
|
+
let line = stars[r] ?? " ".repeat(safeWidth);
|
|
107
|
+
|
|
108
|
+
if (r === 0) line = overlay(line, titlePlaced.line, safeWidth);
|
|
109
|
+
if (r === taglineRow) line = overlay(line, taglinePlaced.line, safeWidth);
|
|
110
|
+
if (r === statsRow) line = overlay(line, statsPlaced.line, safeWidth);
|
|
111
|
+
if (r === hintRow) line = overlay(line, hintPlaced.line, safeWidth);
|
|
112
|
+
|
|
113
|
+
if (r >= artStartRow && r < artStartRow + ART_HEIGHT) {
|
|
114
|
+
const artLine = artLines[r - artStartRow];
|
|
115
|
+
const artLineWidth = visibleWidth(artLine);
|
|
116
|
+
line = overlayAt(line, " ".repeat(artStartCol) + artLine, safeWidth, artStartCol, artLineWidth);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
composed.push(line);
|
|
120
|
+
}
|
|
121
|
+
return composed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function overlay(starLine: string, payload: string, width: number): string {
|
|
125
|
+
return truncate(payload, width);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function overlayAt(_starLine: string, payload: string, width: number, _startCol: number, _payloadWidth: number): string {
|
|
129
|
+
return truncate(payload, width);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function truncate(line: string, width: number): string {
|
|
133
|
+
return visibleWidth(line) <= width ? line : line.slice(0, width);
|
|
134
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
type Handler = (event: unknown, ctx: unknown) => void | Promise<void>;
|
|
4
|
+
|
|
5
|
+
interface FakePi {
|
|
6
|
+
handlers: Record<string, Handler>;
|
|
7
|
+
getAllTools: ReturnType<typeof vi.fn>;
|
|
8
|
+
on: (event: string, h: Handler) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makePi(): FakePi {
|
|
12
|
+
const handlers: Record<string, Handler> = {};
|
|
13
|
+
return {
|
|
14
|
+
handlers,
|
|
15
|
+
getAllTools: vi.fn(() => [{ name: "a" }, { name: "b" }, { name: "c" }]),
|
|
16
|
+
on: (event, h) => {
|
|
17
|
+
handlers[event] = h;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FakeCtx {
|
|
23
|
+
hasUI: boolean;
|
|
24
|
+
cwd: string;
|
|
25
|
+
model: { id: string; contextWindow: number } | undefined;
|
|
26
|
+
sessionManager: { getEntries: () => unknown[] };
|
|
27
|
+
ui: {
|
|
28
|
+
setHeader: ReturnType<typeof vi.fn>;
|
|
29
|
+
onTerminalInput: ReturnType<typeof vi.fn>;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
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
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
hasUI: true,
|
|
43
|
+
cwd: "/Users/astro/proj",
|
|
44
|
+
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;
|
|
52
|
+
},
|
|
53
|
+
...overrides,
|
|
54
|
+
} as FakeCtx & { _terminalListener: ((data: string) => unknown) | null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("astro-welcome extension", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
vi.resetModules();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.useRealTimers();
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function install(): Promise<{ pi: FakePi; mod: typeof import("./index.ts") }> {
|
|
69
|
+
const mod = await import("./index.ts");
|
|
70
|
+
const pi = makePi();
|
|
71
|
+
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
72
|
+
return { pi, mod };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it("registers session_start and before_agent_start listeners", async () => {
|
|
76
|
+
const { pi } = await install();
|
|
77
|
+
expect(pi.handlers.session_start).toBeDefined();
|
|
78
|
+
expect(pi.handlers.before_agent_start).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
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 ctx.hasUI is false", async () => {
|
|
90
|
+
const { pi } = await install();
|
|
91
|
+
const ctx = makeCtx({ hasUI: false });
|
|
92
|
+
await pi.handlers.session_start({ reason: "startup" }, ctx);
|
|
93
|
+
expect(ctx.ui.setHeader).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("clears the header automatically after 6 seconds", async () => {
|
|
97
|
+
const { pi } = await install();
|
|
98
|
+
const ctx = makeCtx();
|
|
99
|
+
await pi.handlers.session_start({ reason: "startup" }, ctx);
|
|
100
|
+
vi.advanceTimersByTime(6_000);
|
|
101
|
+
const calls = ctx.ui.setHeader.mock.calls;
|
|
102
|
+
expect(calls.length).toBe(2);
|
|
103
|
+
expect(calls[1][0]).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("clears the header on the first terminal input keystroke", async () => {
|
|
107
|
+
const { pi } = await install();
|
|
108
|
+
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);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("clears the header on before_agent_start", async () => {
|
|
118
|
+
const { pi } = await install();
|
|
119
|
+
const ctx = makeCtx();
|
|
120
|
+
await pi.handlers.session_start({ reason: "startup" }, ctx);
|
|
121
|
+
await pi.handlers.before_agent_start({ prompt: "hi" }, ctx);
|
|
122
|
+
expect(ctx.ui.setHeader).toHaveBeenLastCalledWith(undefined);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("dismiss is idempotent — second trigger does not double-clear", async () => {
|
|
126
|
+
const { pi } = await install();
|
|
127
|
+
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;
|
|
131
|
+
vi.advanceTimersByTime(6_000);
|
|
132
|
+
expect(ctx.ui.setHeader.mock.calls.length).toBe(before);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("renders header with all stats and dismiss hint visible", async () => {
|
|
136
|
+
const { pi } = await install();
|
|
137
|
+
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);
|
|
144
|
+
const joined = lines.join("\n");
|
|
145
|
+
expect(joined).toContain("ASTRO PI");
|
|
146
|
+
expect(joined).toContain("anthropic/claude-sonnet-4-6");
|
|
147
|
+
expect(joined).toContain("1.0M ctx");
|
|
148
|
+
expect(joined).toContain("3 extensions");
|
|
149
|
+
expect(joined).toContain("Press any key");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reads caveman level from session entries", async () => {
|
|
153
|
+
const { pi } = await install();
|
|
154
|
+
const ctx = makeCtx({
|
|
155
|
+
sessionManager: {
|
|
156
|
+
getEntries: () => [
|
|
157
|
+
{ type: "custom", customType: "caveman-mode", data: { level: "ultra" } },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
});
|
|
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");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { composeWelcome, type WelcomeStats } from "./compose.ts";
|
|
3
|
+
|
|
4
|
+
const DISMISS_AFTER_MS = 6_000;
|
|
5
|
+
|
|
6
|
+
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];
|
|
9
|
+
if (entry.type === "custom" && entry.customType === "caveman-mode") {
|
|
10
|
+
const data = entry.data as { level?: string } | undefined;
|
|
11
|
+
return data?.level ?? undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
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
|
+
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
|
+
}
|
|
52
|
+
|
|
53
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
54
|
+
if (!ctx.hasUI) return;
|
|
55
|
+
dismissed = false;
|
|
56
|
+
activeCtx = ctx;
|
|
57
|
+
const seed = Date.now() & 0xffff_ffff;
|
|
58
|
+
|
|
59
|
+
const stats: WelcomeStats = {
|
|
60
|
+
modelDisplay: modelDisplayShort(ctx.model?.id),
|
|
61
|
+
contextWindow: ctx.model?.contextWindow,
|
|
62
|
+
extensionCount: pi.getAllTools().length,
|
|
63
|
+
cavemanLevel: readCavemanLevel(ctx),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
ctx.ui.setHeader((_tui, _theme) => ({
|
|
67
|
+
invalidate() {},
|
|
68
|
+
render(width: number): string[] {
|
|
69
|
+
return composeWelcome(width, seed, stats);
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
|
|
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
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.on("before_agent_start", async () => {
|
|
85
|
+
dismiss();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { MESSAGES, pickMessage } from "./messages.ts";
|
|
3
|
+
|
|
4
|
+
describe("pickMessage", () => {
|
|
5
|
+
it("returns one of the known messages", () => {
|
|
6
|
+
const msg = pickMessage(0);
|
|
7
|
+
expect(MESSAGES).toContain(msg as typeof MESSAGES[number]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("is deterministic for the same seed", () => {
|
|
11
|
+
expect(pickMessage(42)).toBe(pickMessage(42));
|
|
12
|
+
expect(pickMessage(123_456)).toBe(pickMessage(123_456));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("handles negative seeds", () => {
|
|
16
|
+
const msg = pickMessage(-7);
|
|
17
|
+
expect(MESSAGES).toContain(msg as typeof MESSAGES[number]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const MESSAGES = [
|
|
2
|
+
"Mission control reports you survived another deploy.",
|
|
3
|
+
"Cosmic radiation suggests today is a good debugging day.",
|
|
4
|
+
"Houston confirms you have go for prompt submission.",
|
|
5
|
+
"The void is patient. Take your time — the tokens are not.",
|
|
6
|
+
"Telemetry shows your last session reached escape velocity.",
|
|
7
|
+
"Compiler dreams of you. The linter, less so.",
|
|
8
|
+
"Oxygen at 100%. Caffeine: unmonitored.",
|
|
9
|
+
"Stardate today. Coils warm. Lockfile: ${'pnpm'} only.",
|
|
10
|
+
"Approaching the next bug at relativistic speed.",
|
|
11
|
+
"Warp coils nominal. ESLint: still grumpy.",
|
|
12
|
+
"All thrusters green. The stack trace is the universe whispering.",
|
|
13
|
+
"Galactic standard time exceeded. Resume coding.",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export function pickMessage(seed: number): string {
|
|
17
|
+
const idx = Math.abs(seed) % MESSAGES.length;
|
|
18
|
+
return MESSAGES[idx];
|
|
19
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { fg, PALETTE, type RGB } from "./colors.ts";
|
|
2
|
+
|
|
3
|
+
const STAR_GLYPHS = ["·", "·", "·", "✦", "✧", "⋆", "*"];
|
|
4
|
+
const STAR_COLORS: readonly RGB[] = [
|
|
5
|
+
PALETTE.starDim,
|
|
6
|
+
PALETTE.starDim,
|
|
7
|
+
PALETTE.starBright,
|
|
8
|
+
PALETTE.starGold,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function mulberry32(a: number): () => number {
|
|
12
|
+
let t = a >>> 0;
|
|
13
|
+
return () => {
|
|
14
|
+
t += 0x6d2b79f5;
|
|
15
|
+
let r = t;
|
|
16
|
+
r = Math.imul(r ^ (r >>> 15), r | 1);
|
|
17
|
+
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
|
|
18
|
+
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Mask {
|
|
23
|
+
rows: number;
|
|
24
|
+
cols: number;
|
|
25
|
+
isOccupied(row: number, col: number): boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderStarfield(width: number, height: number, seed: number, mask: Mask): string[] {
|
|
29
|
+
const rng = mulberry32(seed);
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
const density = 0.06;
|
|
32
|
+
for (let y = 0; y < height; y++) {
|
|
33
|
+
let line = "";
|
|
34
|
+
for (let x = 0; x < width; x++) {
|
|
35
|
+
if (mask.isOccupied(y, x)) {
|
|
36
|
+
line += " ";
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (rng() < density) {
|
|
40
|
+
const glyph = STAR_GLYPHS[Math.floor(rng() * STAR_GLYPHS.length)];
|
|
41
|
+
const colour = STAR_COLORS[Math.floor(rng() * STAR_COLORS.length)];
|
|
42
|
+
line += fg(colour, glyph);
|
|
43
|
+
} else {
|
|
44
|
+
line += " ";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
lines.push(line);
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
}
|