@oh-my-pi/pi-coding-agent 15.6.0 → 15.7.0

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.
Files changed (140) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/capability/rule-buckets.d.ts +30 -0
  3. package/dist/types/capability/rule.d.ts +7 -0
  4. package/dist/types/cli/completion-gen.d.ts +80 -0
  5. package/dist/types/commands/complete.d.ts +6 -0
  6. package/dist/types/commands/completions.d.ts +13 -0
  7. package/dist/types/commands/setup.d.ts +10 -1
  8. package/dist/types/config/settings-schema.d.ts +170 -10
  9. package/dist/types/discovery/builtin-defaults.d.ts +1 -0
  10. package/dist/types/discovery/builtin-rules/index.d.ts +7 -0
  11. package/dist/types/discovery/index.d.ts +1 -0
  12. package/dist/types/edit/hashline/block-resolver.d.ts +9 -0
  13. package/dist/types/edit/hashline/index.d.ts +1 -0
  14. package/dist/types/eval/py/kernel.d.ts +3 -0
  15. package/dist/types/eval/py/runtime.d.ts +11 -1
  16. package/dist/types/export/html/template.generated.d.ts +1 -1
  17. package/dist/types/main.d.ts +1 -0
  18. package/dist/types/modes/components/index.d.ts +1 -0
  19. package/dist/types/modes/components/segment-track.d.ts +22 -0
  20. package/dist/types/modes/components/welcome.d.ts +21 -0
  21. package/dist/types/modes/interactive-mode.d.ts +3 -2
  22. package/dist/types/modes/setup-wizard/index.d.ts +16 -0
  23. package/dist/types/modes/setup-wizard/scenes/glyph.d.ts +2 -0
  24. package/dist/types/modes/setup-wizard/scenes/outro.d.ts +2 -0
  25. package/dist/types/modes/setup-wizard/scenes/providers.d.ts +2 -0
  26. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +19 -0
  27. package/dist/types/modes/setup-wizard/scenes/splash.d.ts +11 -0
  28. package/dist/types/modes/setup-wizard/scenes/theme.d.ts +2 -0
  29. package/dist/types/modes/setup-wizard/scenes/types.d.ts +43 -0
  30. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +19 -0
  31. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +14 -0
  32. package/dist/types/modes/theme/shimmer.d.ts +2 -0
  33. package/dist/types/modes/theme/theme.d.ts +11 -0
  34. package/dist/types/modes/types.d.ts +5 -1
  35. package/dist/types/tiny/device.d.ts +78 -0
  36. package/dist/types/tiny/dtype.d.ts +85 -0
  37. package/dist/types/tiny/models.d.ts +6 -6
  38. package/dist/types/tiny/text.d.ts +15 -0
  39. package/dist/types/tiny/title-client.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +0 -1
  41. package/dist/types/tools/eval.d.ts +1 -1
  42. package/dist/types/tools/index.d.ts +0 -1
  43. package/dist/types/tui/code-cell.d.ts +2 -0
  44. package/dist/types/tui/output-block.d.ts +17 -0
  45. package/package.json +9 -9
  46. package/src/capability/rule-buckets.ts +64 -0
  47. package/src/capability/rule.ts +8 -0
  48. package/src/cli/completion-gen.ts +550 -0
  49. package/src/cli/setup-cli.ts +5 -3
  50. package/src/cli-commands.ts +2 -0
  51. package/src/cli.ts +1 -7
  52. package/src/commands/complete.ts +66 -0
  53. package/src/commands/completions.ts +60 -0
  54. package/src/commands/setup.ts +29 -4
  55. package/src/config/settings-schema.ts +70 -11
  56. package/src/discovery/builtin-defaults.ts +39 -0
  57. package/src/discovery/builtin-rules/index.ts +48 -0
  58. package/src/discovery/builtin-rules/rs-box-leak.md +48 -0
  59. package/src/discovery/builtin-rules/rs-future-prelude.md +23 -0
  60. package/src/discovery/builtin-rules/rs-lazylock.md +51 -0
  61. package/src/discovery/builtin-rules/rs-match-ergonomics.md +67 -0
  62. package/src/discovery/builtin-rules/rs-parking-lot.md +44 -0
  63. package/src/discovery/builtin-rules/rs-result-type.md +19 -0
  64. package/src/discovery/builtin-rules/ts-bare-catch.md +38 -0
  65. package/src/discovery/builtin-rules/ts-import-type.md +42 -0
  66. package/src/discovery/builtin-rules/ts-no-any.md +56 -0
  67. package/src/discovery/builtin-rules/ts-no-dynamic-import.md +39 -0
  68. package/src/discovery/builtin-rules/ts-no-return-type.md +45 -0
  69. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +50 -0
  70. package/src/discovery/builtin-rules/ts-promise-with-resolvers.md +65 -0
  71. package/src/discovery/builtin-rules/ts-set-map.md +28 -0
  72. package/src/discovery/index.ts +1 -0
  73. package/src/edit/hashline/block-resolver.ts +14 -0
  74. package/src/edit/hashline/diff.ts +4 -1
  75. package/src/edit/hashline/execute.ts +2 -1
  76. package/src/edit/hashline/index.ts +1 -0
  77. package/src/eval/py/kernel.ts +37 -15
  78. package/src/eval/py/runtime.ts +57 -28
  79. package/src/export/html/template.generated.ts +1 -1
  80. package/src/export/html/template.js +0 -12
  81. package/src/export/ttsr.ts +2 -0
  82. package/src/internal-urls/docs-index.generated.ts +7 -8
  83. package/src/main.ts +18 -1
  84. package/src/modes/components/hook-selector.ts +15 -17
  85. package/src/modes/components/index.ts +1 -0
  86. package/src/modes/components/segment-track.ts +52 -0
  87. package/src/modes/components/tips.txt +2 -1
  88. package/src/modes/components/tool-execution.ts +5 -1
  89. package/src/modes/components/welcome.ts +47 -42
  90. package/src/modes/controllers/input-controller.ts +12 -21
  91. package/src/modes/interactive-mode.ts +17 -5
  92. package/src/modes/setup-wizard/index.ts +88 -0
  93. package/src/modes/setup-wizard/scenes/glyph.ts +96 -0
  94. package/src/modes/setup-wizard/scenes/outro.ts +35 -0
  95. package/src/modes/setup-wizard/scenes/providers.ts +69 -0
  96. package/src/modes/setup-wizard/scenes/sign-in.ts +193 -0
  97. package/src/modes/setup-wizard/scenes/splash.ts +201 -0
  98. package/src/modes/setup-wizard/scenes/theme.ts +299 -0
  99. package/src/modes/setup-wizard/scenes/types.ts +48 -0
  100. package/src/modes/setup-wizard/scenes/web-search.ts +128 -0
  101. package/src/modes/setup-wizard/wizard-overlay.ts +275 -0
  102. package/src/modes/theme/shimmer.ts +5 -0
  103. package/src/modes/theme/theme.ts +44 -20
  104. package/src/modes/types.ts +6 -1
  105. package/src/prompts/system/orchestrate-notice.md +1 -1
  106. package/src/prompts/tools/read.md +4 -0
  107. package/src/sdk.ts +5 -15
  108. package/src/slash-commands/builtin-registry.ts +8 -0
  109. package/src/tiny/device.ts +117 -0
  110. package/src/tiny/dtype.ts +101 -0
  111. package/src/tiny/models.ts +7 -6
  112. package/src/tiny/text.ts +36 -1
  113. package/src/tiny/title-client.ts +58 -3
  114. package/src/tiny/worker.ts +93 -29
  115. package/src/tools/bash.ts +16 -13
  116. package/src/tools/eval.ts +9 -4
  117. package/src/tools/index.ts +0 -11
  118. package/src/tools/read.ts +1 -0
  119. package/src/tools/renderers.ts +0 -2
  120. package/src/tui/code-cell.ts +6 -1
  121. package/src/tui/output-block.ts +199 -38
  122. package/dist/types/tools/recipe/index.d.ts +0 -46
  123. package/dist/types/tools/recipe/render.d.ts +0 -36
  124. package/dist/types/tools/recipe/runner.d.ts +0 -60
  125. package/dist/types/tools/recipe/runners/cargo.d.ts +0 -16
  126. package/dist/types/tools/recipe/runners/index.d.ts +0 -2
  127. package/dist/types/tools/recipe/runners/just.d.ts +0 -2
  128. package/dist/types/tools/recipe/runners/make.d.ts +0 -2
  129. package/dist/types/tools/recipe/runners/pkg.d.ts +0 -2
  130. package/dist/types/tools/recipe/runners/task.d.ts +0 -2
  131. package/src/prompts/tools/recipe.md +0 -16
  132. package/src/tools/recipe/index.ts +0 -81
  133. package/src/tools/recipe/render.ts +0 -19
  134. package/src/tools/recipe/runner.ts +0 -219
  135. package/src/tools/recipe/runners/cargo.ts +0 -131
  136. package/src/tools/recipe/runners/index.ts +0 -8
  137. package/src/tools/recipe/runners/just.ts +0 -73
  138. package/src/tools/recipe/runners/make.ts +0 -101
  139. package/src/tools/recipe/runners/pkg.ts +0 -167
  140. package/src/tools/recipe/runners/task.ts +0 -72
@@ -0,0 +1,69 @@
1
+ import { TabBar } from "@oh-my-pi/pi-tui";
2
+ import { getTabBarTheme } from "../../shared";
3
+ import { SignInTab } from "./sign-in";
4
+ import type { SetupScene, SetupSceneController, SetupSceneHost, SetupTab } from "./types";
5
+ import { WebSearchTab } from "./web-search";
6
+
7
+ /**
8
+ * Tabbed "Set up your providers" scene. Composes independent panels (model
9
+ * sign-in, web search) behind a {@link TabBar}; the active panel owns
10
+ * rendering and input, while modal panels (e.g. an in-flight OAuth login)
11
+ * temporarily suppress tab switching.
12
+ */
13
+ class ProvidersSceneController implements SetupSceneController {
14
+ title = "Set up your providers";
15
+ subtitle = "Sign in and pick a web search provider. Press Esc when you're done.";
16
+
17
+ #tabs: SetupTab[];
18
+ #tabBar: TabBar;
19
+
20
+ constructor(host: SetupSceneHost) {
21
+ this.#tabs = [new SignInTab(host), new WebSearchTab(host)];
22
+ this.#tabBar = new TabBar(
23
+ "Providers",
24
+ this.#tabs.map(tab => ({ id: tab.id, label: tab.label })),
25
+ getTabBarTheme(),
26
+ );
27
+ this.#tabBar.onTabChange = () => {
28
+ this.#activeTab().onActivate?.();
29
+ host.requestRender();
30
+ };
31
+ }
32
+
33
+ #activeTab(): SetupTab {
34
+ return this.#tabs[this.#tabBar.getActiveIndex()] ?? this.#tabs[0];
35
+ }
36
+
37
+ onMount(): void {
38
+ this.#activeTab().onActivate?.();
39
+ }
40
+
41
+ invalidate(): void {
42
+ for (const tab of this.#tabs) tab.invalidate();
43
+ }
44
+
45
+ handleInput(data: string): void {
46
+ const tab = this.#activeTab();
47
+ if (tab.modal) {
48
+ tab.handleInput(data);
49
+ return;
50
+ }
51
+ if (this.#tabBar.handleInput(data)) return;
52
+ tab.handleInput(data);
53
+ }
54
+
55
+ render(width: number): string[] {
56
+ return [...this.#tabBar.render(width), "", ...this.#activeTab().render(width)];
57
+ }
58
+
59
+ dispose(): void {
60
+ for (const tab of this.#tabs) tab.dispose();
61
+ }
62
+ }
63
+
64
+ export const providersSetupScene: SetupScene = {
65
+ id: "providers",
66
+ title: "Set up your providers",
67
+ minVersion: 1,
68
+ mount: host => new ProvidersSceneController(host),
69
+ };
@@ -0,0 +1,193 @@
1
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
+ import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
3
+ import { Input, matchesKey, truncateToWidth } from "@oh-my-pi/pi-tui";
4
+ import { getAgentDbPath } from "@oh-my-pi/pi-utils";
5
+ import { OAuthSelectorComponent } from "../../components/oauth-selector";
6
+ import { theme } from "../../theme/theme";
7
+ import type { SetupSceneHost, SetupTab } from "./types";
8
+
9
+ /** Providers whose OAuth flow needs a pasted code/redirect URL rather than a callback server. */
10
+ const CALLBACK_SERVER_PROVIDERS: Partial<Record<OAuthProvider, true>> = {
11
+ anthropic: true,
12
+ "openai-codex": true,
13
+ "gitlab-duo": true,
14
+ "google-gemini-cli": true,
15
+ "google-antigravity": true,
16
+ };
17
+
18
+ interface PromptState {
19
+ message: string;
20
+ placeholder?: string;
21
+ input: Input;
22
+ }
23
+
24
+ /**
25
+ * "Sign in" panel: lets the user authenticate one or more model providers via
26
+ * OAuth. Unlike a standalone scene it never auto-advances the wizard — the user
27
+ * may sign in to several providers and then continue with Esc.
28
+ */
29
+ export class SignInTab implements SetupTab {
30
+ readonly id = "sign-in";
31
+ readonly label = "Sign in";
32
+
33
+ #authStorage: AuthStorage;
34
+ #selector: OAuthSelectorComponent;
35
+ #statusLines: string[] = [];
36
+ #prompt: PromptState | undefined;
37
+ #promptResolve: ((value: string) => void) | undefined;
38
+ #loginAbort: AbortController | undefined;
39
+ #loggingInProvider: string | undefined;
40
+ #disposed = false;
41
+
42
+ constructor(private readonly host: SetupSceneHost) {
43
+ this.#authStorage = host.ctx.session.modelRegistry.authStorage;
44
+ this.#selector = this.#createSelector();
45
+ }
46
+
47
+ /** Modal while an OAuth flow is running so the scene won't switch tabs or finish. */
48
+ get modal(): boolean {
49
+ return this.#loggingInProvider !== undefined;
50
+ }
51
+
52
+ dispose(): void {
53
+ this.#disposed = true;
54
+ this.#selector.stopValidation();
55
+ this.#loginAbort?.abort();
56
+ this.#resolvePrompt("");
57
+ }
58
+
59
+ invalidate(): void {
60
+ this.#selector.invalidate();
61
+ this.#prompt?.input.invalidate();
62
+ }
63
+
64
+ handleInput(data: string): void {
65
+ if (this.#loggingInProvider) {
66
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
67
+ this.#loginAbort?.abort();
68
+ }
69
+ return;
70
+ }
71
+ this.#selector.handleInput(data);
72
+ }
73
+
74
+ render(width: number): string[] {
75
+ const lines = [theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), ""];
76
+ if (this.#loggingInProvider) {
77
+ lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`), "");
78
+ } else {
79
+ lines.push(...this.#selector.render(width));
80
+ }
81
+ if (this.#statusLines.length > 0) {
82
+ lines.push("", ...this.#statusLines.map(line => truncateToWidth(line, width)));
83
+ }
84
+ if (this.#prompt) {
85
+ lines.push("", theme.fg("warning", this.#prompt.message));
86
+ if (this.#prompt.placeholder) {
87
+ lines.push(theme.fg("dim", this.#prompt.placeholder));
88
+ }
89
+ lines.push(this.#prompt.input.render(width)[0] ?? "");
90
+ }
91
+ return lines;
92
+ }
93
+
94
+ #createSelector(): OAuthSelectorComponent {
95
+ return new OAuthSelectorComponent(
96
+ "login",
97
+ this.#authStorage,
98
+ providerId => {
99
+ void this.#login(providerId);
100
+ },
101
+ () => this.host.finish("skipped"),
102
+ { requestRender: () => this.host.requestRender() },
103
+ );
104
+ }
105
+
106
+ async #login(providerId: string): Promise<void> {
107
+ if (this.#loggingInProvider || this.#disposed) return;
108
+ const useManualInput = CALLBACK_SERVER_PROVIDERS[providerId as OAuthProvider] === true;
109
+ this.#selector.stopValidation();
110
+ this.#loggingInProvider = providerId;
111
+ this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")];
112
+ this.#loginAbort = new AbortController();
113
+ this.host.restoreFocus();
114
+ this.host.requestRender();
115
+ try {
116
+ await this.#authStorage.login(providerId as OAuthProvider, {
117
+ signal: this.#loginAbort.signal,
118
+ onAuth: info => {
119
+ this.#statusLines.push(theme.fg("accent", `Open this URL: ${info.url}`));
120
+ if (info.instructions) {
121
+ this.#statusLines.push(theme.fg("warning", info.instructions));
122
+ }
123
+ if (useManualInput) {
124
+ this.#statusLines.push(theme.fg("dim", "Paste the returned code or redirect URL when prompted."));
125
+ }
126
+ this.host.ctx.openInBrowser(info.url);
127
+ this.host.requestRender();
128
+ },
129
+ onPrompt: prompt => this.#showPrompt(prompt),
130
+ onProgress: message => {
131
+ this.#statusLines.push(theme.fg("dim", message));
132
+ this.host.requestRender();
133
+ },
134
+ onManualCodeInput: () =>
135
+ this.#showPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
136
+ });
137
+ await this.host.ctx.session.modelRegistry.refresh();
138
+ if (this.#disposed) return;
139
+ this.#statusLines = [
140
+ theme.fg("success", `${theme.status.success} Signed in to ${providerId}`),
141
+ theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`),
142
+ ];
143
+ this.#loggingInProvider = undefined;
144
+ this.#loginAbort = undefined;
145
+ this.#selector.stopValidation();
146
+ this.#selector = this.#createSelector();
147
+ this.host.restoreFocus();
148
+ this.host.requestRender();
149
+ } catch (error) {
150
+ if (this.#disposed) return;
151
+ if (this.#loginAbort?.signal.aborted) {
152
+ this.#statusLines = [theme.fg("dim", "Login cancelled.")];
153
+ } else {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ this.#statusLines = [
156
+ theme.fg("error", `Login failed: ${message}`),
157
+ theme.fg("dim", "Choose another provider or press Esc to continue."),
158
+ ];
159
+ }
160
+ this.#loggingInProvider = undefined;
161
+ this.#loginAbort = undefined;
162
+ this.host.restoreFocus();
163
+ this.host.requestRender();
164
+ }
165
+ }
166
+
167
+ #showPrompt(prompt: { message: string; placeholder?: string }): Promise<string> {
168
+ this.#resolvePrompt("");
169
+ const input = new Input();
170
+ const pending = Promise.withResolvers<string>();
171
+ this.#promptResolve = pending.resolve;
172
+ this.#prompt = { message: prompt.message, placeholder: prompt.placeholder, input };
173
+ input.onSubmit = value => {
174
+ this.#resolvePrompt(value);
175
+ };
176
+ input.onEscape = () => {
177
+ this.#resolvePrompt("");
178
+ };
179
+ this.host.setFocus(input);
180
+ this.host.requestRender();
181
+ return pending.promise;
182
+ }
183
+
184
+ #resolvePrompt(value: string): void {
185
+ const resolve = this.#promptResolve;
186
+ if (!resolve) return;
187
+ this.#promptResolve = undefined;
188
+ this.#prompt = undefined;
189
+ this.host.restoreFocus();
190
+ resolve(value);
191
+ this.host.requestRender();
192
+ }
193
+ }
@@ -0,0 +1,201 @@
1
+ import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import { gradientEscape, gradientLogo, PI_LOGO, type ShineConfig } from "../../components/welcome";
3
+ import { theme } from "../../theme/theme";
4
+
5
+ export const SETUP_SPLASH_MS = 2600;
6
+ export const SETUP_TICK_MS = 33;
7
+
8
+ /** Brand mark at 2x: every glyph doubled horizontally, every row doubled vertically. */
9
+ const LARGE_LOGO = PI_LOGO.flatMap(line => {
10
+ let wide = "";
11
+ for (const char of line) {
12
+ wide += char === " " ? " " : `${char}${char}`;
13
+ }
14
+ return [wide, wide];
15
+ });
16
+ const LOGO_WIDTH = Math.max(...LARGE_LOGO.map(line => visibleWidth(line)));
17
+ const LOGO_HEIGHT = LARGE_LOGO.length;
18
+ const RESET = "\x1b[0m";
19
+
20
+ /** Full scene needs comfortable room; below this we drop to a centered mark. */
21
+ const MIN_SCENE_WIDTH = 56;
22
+ const MIN_SCENE_HEIGHT = 22;
23
+
24
+ const SKIP_HINT = "press enter to skip";
25
+
26
+ /** Density ramp for the rippling water, lightest → heaviest. */
27
+ const WATER_RAMP = [
28
+ { min: 0.62, char: "█" },
29
+ { min: 0.5, char: "▓" },
30
+ { min: 0.36, char: "▒" },
31
+ { min: 0.24, char: "░" },
32
+ ];
33
+
34
+ function clampLine(line: string, width: number): string {
35
+ const truncated = truncateToWidth(line, width);
36
+ return truncated + padding(Math.max(0, width - visibleWidth(truncated)));
37
+ }
38
+
39
+ function centerLine(line: string, width: number): string {
40
+ const lineWidth = visibleWidth(line);
41
+ if (lineWidth >= width) return truncateToWidth(line, width);
42
+ const left = Math.floor((width - lineWidth) / 2);
43
+ return padding(left) + line + padding(width - left - lineWidth);
44
+ }
45
+
46
+ function starAt(x: number, y: number, frame: number): string {
47
+ const hash = (x * 73856093) ^ (y * 19349663) ^ (frame * 83492791);
48
+ const bucket = Math.abs(hash) % 97;
49
+ if (bucket === 0) return theme.fg("accent", "✦");
50
+ if (bucket === 1) return theme.fg("muted", "·");
51
+ return " ";
52
+ }
53
+
54
+ export function renderStarfield(width: number, height: number, frame: number): string[] {
55
+ const lines: string[] = [];
56
+ for (let y = 0; y < height; y++) {
57
+ let line = "";
58
+ for (let x = 0; x < width; x++) {
59
+ line += starAt(x, y, frame >> 3);
60
+ }
61
+ lines.push(line);
62
+ }
63
+ return lines;
64
+ }
65
+
66
+ /** Continuous diagonal gradient position (bottom-left → top-right) across the whole screen. */
67
+ function screenGradientT(x: number, y: number, width: number, height: number, phase: number): number {
68
+ const span = Math.max(1, width + height - 1);
69
+ const base = (x + (height - 1 - y)) / span;
70
+ return (((base + phase) % 1) + 1) % 1;
71
+ }
72
+
73
+ /** Twinkling sparkle for the upper "sky". Returns a styled glyph, or null for empty space. */
74
+ function skyGlyph(x: number, y: number, frame: number): string | null {
75
+ const hash = (x * 73856093) ^ (y * 19349663) ^ (frame * 83492791);
76
+ const bucket = Math.abs(hash) % 150;
77
+ if (bucket === 0) return theme.fg("accent", "✦");
78
+ if (bucket === 1) return theme.fg("border", "✧");
79
+ if (bucket === 2) return theme.fg("border", "·");
80
+ return null;
81
+ }
82
+
83
+ /** Static value-jitter in [0,1) that softens the water's threshold banding. */
84
+ function waterJitter(x: number, y: number): number {
85
+ let h = Math.imul(x, 374761393) + Math.imul(y, 668265263);
86
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
87
+ h ^= h >>> 16;
88
+ return (h >>> 0) / 4294967296;
89
+ }
90
+
91
+ /**
92
+ * Rippling water amplitude in [0,1] at (x, y): three travelling sine waves
93
+ * interfere, then a radial edge falloff and a downward fade concentrate the
94
+ * ripples beneath the mark and dissolve them toward the edges/bottom. `t`
95
+ * advances each tick, so the surface drifts.
96
+ */
97
+ function waterAmplitude(
98
+ x: number,
99
+ y: number,
100
+ cx: number,
101
+ waterTop: number,
102
+ waterHeight: number,
103
+ width: number,
104
+ t: number,
105
+ ): number {
106
+ const dx = (x - cx) / 2;
107
+ const dy = y - waterTop;
108
+ const dist = Math.sqrt(dx * dx + dy * dy);
109
+ const wave =
110
+ 0.5 * Math.sin(dist * 0.55 - t) +
111
+ 0.3 * Math.sin(x * 0.22 + y * 0.45 - t * 0.7) +
112
+ 0.2 * Math.sin(Math.abs(dx) * 0.8 + dy * 0.5 - t * 1.4);
113
+ const level = 0.5 + 0.5 * wave;
114
+ const edge = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.5));
115
+ const fade = Math.max(0, 1 - (dy / Math.max(1, waterHeight)) * 0.55);
116
+ return level * edge ** 0.7 * fade;
117
+ }
118
+
119
+ /**
120
+ * Animated setup splash, in the spirit of the omp landing page: the brand π
121
+ * mark rendered with the live diagonal gradient + shine sweep, rising out of a
122
+ * rippling, gradient-lit water surface, under a faint twinkling starfield. The
123
+ * mark and water share one continuous gradient so the sweep reads across the
124
+ * whole scene; the water surface drifts each frame.
125
+ */
126
+ export function renderSetupSplash(width: number, height: number, elapsedMs: number): string[] {
127
+ const w = Math.max(1, width);
128
+ const h = Math.max(1, height);
129
+ const progress = Math.max(0, Math.min(1, elapsedMs / SETUP_SPLASH_MS));
130
+ const phase = progress * 1.8;
131
+ const shine: ShineConfig = { pos: (progress * 2.5) % 1, strength: Math.max(0, 1 - progress * 0.35) };
132
+
133
+ if (w < MIN_SCENE_WIDTH || h < MIN_SCENE_HEIGHT) return renderCompactSplash(w, h, phase, shine);
134
+
135
+ const frame = Math.floor(elapsedMs / SETUP_TICK_MS);
136
+ const cx = Math.floor(w / 2);
137
+ const surfaceTime = frame * 0.13;
138
+
139
+ const cells: string[][] = Array.from({ length: h }, () => new Array<string>(w).fill(" "));
140
+ const put = (x: number, y: number, glyph: string): void => {
141
+ if (y >= 0 && y < h && x >= 0 && x < w) cells[y][x] = glyph;
142
+ };
143
+
144
+ const hx = Math.floor((w - LOGO_WIDTH) / 2);
145
+ const hy = Math.max(2, Math.floor(h * 0.16));
146
+ const waterTop = hy + LOGO_HEIGHT;
147
+ const waterHeight = Math.max(1, h - waterTop);
148
+
149
+ // 1. rippling water surface (shares the screen-wide gradient with the mark)
150
+ for (let y = waterTop; y < h; y++) {
151
+ for (let x = 0; x < w; x++) {
152
+ const amp = waterAmplitude(x, y, cx, waterTop, waterHeight, w, surfaceTime) + (waterJitter(x, y) - 0.5) * 0.06;
153
+ const cell = WATER_RAMP.find(step => amp > step.min);
154
+ if (cell) put(x, y, gradientEscape(screenGradientT(x, y, w, h, phase), shine) + cell.char + RESET);
155
+ }
156
+ }
157
+ // 2. twinkling starfield in the sky above the water
158
+ for (let y = 0; y < waterTop - 1; y++) {
159
+ for (let x = 0; x < w; x++) {
160
+ const star = skyGlyph(x, y, frame >> 3);
161
+ if (star) put(x, y, star);
162
+ }
163
+ }
164
+ // 3. hero — the brand mark with the live gradient + shine sweep
165
+ LARGE_LOGO.forEach((line, row) => {
166
+ let col = 0;
167
+ for (const ch of line) {
168
+ if (ch !== " ") {
169
+ put(
170
+ hx + col,
171
+ hy + row,
172
+ gradientEscape(screenGradientT(hx + col, hy + row, w, h, phase), shine) + ch + RESET,
173
+ );
174
+ }
175
+ col++;
176
+ }
177
+ });
178
+ // 4. skip hint on a cleared strip at the bottom so it stays legible over the water
179
+ const hintWidth = visibleWidth(SKIP_HINT);
180
+ const hintStart = Math.floor((w - hintWidth) / 2);
181
+ const hintRow = h - 1;
182
+ for (let x = hintStart - 1; x <= hintStart + hintWidth; x++) put(x, hintRow, " ");
183
+ let col = hintStart;
184
+ for (const ch of SKIP_HINT) put(col++, hintRow, ch === " " ? " " : theme.fg("dim", ch));
185
+
186
+ return cells.map(row => row.join(""));
187
+ }
188
+
189
+ /** Centered fallback for windows too small to hold the full scene. */
190
+ function renderCompactSplash(width: number, height: number, phase: number, shine: ShineConfig): string[] {
191
+ const art = height >= 14 ? LARGE_LOGO : PI_LOGO;
192
+ const content = [...gradientLogo(art, phase, shine), "", theme.bold("O h M y P i")];
193
+ const start = Math.max(0, Math.floor((height - content.length) / 2));
194
+ const lines: string[] = [];
195
+ for (let y = 0; y < height; y++) {
196
+ const item = content[y - start];
197
+ lines.push(clampLine(item !== undefined ? centerLine(item, width) : "", width));
198
+ }
199
+ if (height > 2) lines[height - 2] = clampLine(centerLine(theme.fg("dim", SKIP_HINT), width), width);
200
+ return lines;
201
+ }