@linxiraos/pi-tui 1.2.0 → 1.2.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/CHANGELOG.md +5 -0
- package/dist/types/prompt/composer.d.ts +1 -6
- package/dist/types/prompt/welcome.d.ts +17 -45
- package/dist/types/setup/scenes/splash.d.ts +1 -1
- package/package.json +9 -9
- package/src/overlays/agent-hub.ts +2 -2
- package/src/overlays/plugin-settings.ts +8 -2
- package/src/prompt/composer-cache.ts +4 -6
- package/src/prompt/composer.ts +2 -22
- package/src/prompt/welcome.ts +197 -416
- package/src/setup/scenes/splash.ts +116 -53
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.2.1] - 2026-10-13
|
|
4
|
+
|
|
5
|
+
- The welcome box now resolves its labels, the `Tip:` label, the `[NEW]` tag, and every tip body through the host `tuiText` catalogue (`welcome*` and `tipN` keys, where `tipN` maps to `tips.txt` line N). The inline English stays as the permanent fallback, so rendering without a registered source is unchanged; a registered source localises the box and re-renders on a live language switch.
|
|
6
|
+
- Welcome banner restored to the upstream logo lockup: the ζ block mark sits beside a half-block `Zeta` wordmark with the version under it and the session tip below, replacing the two-column card. Model/provider labels, LSP servers and recent sessions are no longer rendered (the same removal upstream shipped in `1fb10d29bc1`), and the setup splash now reuses a cached water field, cell grid and per-frame gradient escapes with a skip hint that follows the live symbol preset.
|
|
7
|
+
|
|
3
8
|
## [1.2.0] - 2026-10-12
|
|
4
9
|
|
|
5
10
|
- Added cache-release hooks for TUI components and tool cards, allowing extensions to discard derived render data without rebuilding content ([#13632](https://github.com/can1357/oh-my-pi/pull/13632) by [@iliaal](https://github.com/iliaal)).
|
|
@@ -6,7 +6,7 @@ import { type Component, type ResizeScrollbackMode, type TerminalFramePlan, type
|
|
|
6
6
|
import type { NativeSurface, NativeSurfaceProvider } from "../native/node.js";
|
|
7
7
|
import { CustomEditor } from "./custom-editor.js";
|
|
8
8
|
import type { WordCompletionMethod } from "./word-completion.js";
|
|
9
|
-
import { WelcomeComponent
|
|
9
|
+
import { WelcomeComponent } from "./welcome.js";
|
|
10
10
|
/** Live settings that affect the composer before and after session adoption. */
|
|
11
11
|
export interface ComposerPreferences {
|
|
12
12
|
readonly quiet: boolean;
|
|
@@ -25,11 +25,6 @@ export declare const COMPOSER_DEFAULTS: ComposerPreferences;
|
|
|
25
25
|
/** Welcome data that can be supplied initially or patched as startup resolves it. */
|
|
26
26
|
export interface ComposerWelcomeUpdate {
|
|
27
27
|
readonly version?: string;
|
|
28
|
-
readonly modelName?: string;
|
|
29
|
-
readonly providerName?: string;
|
|
30
|
-
readonly recentSessions?: readonly RecentSession[];
|
|
31
|
-
/** Detected project servers; `null` means LSP is disabled and hides the welcome section. */
|
|
32
|
-
readonly lspServers?: readonly LspServerInfo[] | null;
|
|
33
28
|
}
|
|
34
29
|
/**
|
|
35
30
|
* Status-bar inputs persisted by the last session. The first frame renders
|
|
@@ -1,63 +1,38 @@
|
|
|
1
|
-
import type { DescribeContext, NativeNode
|
|
1
|
+
import type { DescribeContext, NativeNode } from "../native/node.js";
|
|
2
2
|
import type { Component } from "../tui.js";
|
|
3
|
-
/**
|
|
4
|
-
* Fixed number of session rows in the welcome box so its height stays stable
|
|
5
|
-
* across recent-session updates.
|
|
6
|
-
*/
|
|
7
|
-
export declare const WELCOME_SESSION_SLOTS = 4;
|
|
8
|
-
/**
|
|
9
|
-
* Fixed number of LSP-server rows, for the same reason. Overflow is sliced so
|
|
10
|
-
* the box height is constant regardless of how many servers a project has.
|
|
11
|
-
*/
|
|
12
|
-
export declare const WELCOME_LSP_SLOTS = 4;
|
|
13
3
|
/** Pick a tip from `tips`, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT};
|
|
14
4
|
* `r` is a uniform sample in [0, 1). Returns "" when `tips` is empty.
|
|
15
5
|
* Exported for tests. */
|
|
16
6
|
export declare function pickWeightedTip(tips: readonly string[], r: number): string;
|
|
17
7
|
/**
|
|
18
|
-
* The welcome tip as lines of at most `width` columns:
|
|
19
|
-
* wrapped together, with no indent, so the banner can center
|
|
20
|
-
*
|
|
8
|
+
* The welcome tip as lines of at most `width` columns: the `welcomeTipLabel`
|
|
9
|
+
* label and the body wrapped together, with no indent, so the banner can center
|
|
10
|
+
* each line. `isNew` drives the rainbow "welcomeNewTag" suffix; it is passed in
|
|
11
|
+
* because the marker lives in tips.txt, not in the resolved body. `[]` when
|
|
12
|
+
* `width` leaves no room for a useful line.
|
|
21
13
|
*/
|
|
22
|
-
export declare function renderWelcomeTip(tip: string, width: number, phase?: number): string[];
|
|
23
|
-
export interface RecentSession {
|
|
24
|
-
name: string;
|
|
25
|
-
timeAgo: string;
|
|
26
|
-
/** Session file; a native click on the row resumes it. */
|
|
27
|
-
path?: string;
|
|
28
|
-
}
|
|
29
|
-
export interface LspServerInfo {
|
|
30
|
-
name: string;
|
|
31
|
-
status: "ready" | "error" | "connecting" | "available";
|
|
32
|
-
fileTypes: string[];
|
|
33
|
-
}
|
|
14
|
+
export declare function renderWelcomeTip(tip: string, width: number, phase?: number, isNew?: boolean): string[];
|
|
34
15
|
/**
|
|
35
|
-
*
|
|
16
|
+
* The session's welcome banner. In a terminal: the gradient logo beside the
|
|
17
|
+
* `zeta` wordmark with the version under it (the logo alone when the lockup does
|
|
18
|
+
* not fit) and the tip of the session (dropped below {@link TIP_MIN_COLUMNS}
|
|
19
|
+
* columns). Natively: a card with the same logo, wordmark, version and tip
|
|
20
|
+
* ({@link WelcomeComponent.describe}).
|
|
36
21
|
*/
|
|
37
22
|
export declare class WelcomeComponent implements Component {
|
|
38
23
|
#private;
|
|
39
24
|
private version;
|
|
40
|
-
|
|
41
|
-
private providerName;
|
|
42
|
-
private recentSessions;
|
|
43
|
-
/** Detected project servers; `null` means LSP is disabled and hides the section. */
|
|
44
|
-
private lspServers;
|
|
45
|
-
constructor(version: string, modelName?: string, providerName?: string, recentSessions?: RecentSession[],
|
|
46
|
-
/** Detected project servers; `null` means LSP is disabled and hides the section. */
|
|
47
|
-
lspServers?: LspServerInfo[] | null);
|
|
25
|
+
constructor(version: string);
|
|
48
26
|
get tip(): string | undefined;
|
|
49
27
|
invalidate(): void;
|
|
50
28
|
/**
|
|
51
|
-
* A `card` (`zeta.welcome`)
|
|
52
|
-
* (`zeta.welcome.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* carry the look (gradient logo, type scale, column hairline); a "[NEW]" tip
|
|
29
|
+
* A `card` (`zeta.welcome`) mirroring the terminal banner: the lockup
|
|
30
|
+
* (`zeta.welcome.lockup`: the terminal's builtin `zeta` mark, which it animates,
|
|
31
|
+
* beside the wordmark with the version under it) and the tip of the session.
|
|
32
|
+
* Roles carry the look (gradient logo, type scale); a "[NEW]" tip
|
|
56
33
|
* carries a terminal-clocked shimmering tag.
|
|
57
34
|
*/
|
|
58
35
|
describe(_cx: DescribeContext): NativeNode;
|
|
59
|
-
/** A click on a recent session resumes it. */
|
|
60
|
-
handleNativeEvent(event: NativeUiEvent): void;
|
|
61
36
|
/** The intro keeps the welcome block mutable; settling lets it retire to history. */
|
|
62
37
|
isTranscriptBlockFinalized(): boolean;
|
|
63
38
|
/**
|
|
@@ -76,9 +51,6 @@ export declare class WelcomeComponent implements Component {
|
|
|
76
51
|
stopIntro(): void;
|
|
77
52
|
/** Update the version embedded in the welcome border title. */
|
|
78
53
|
setVersion(version: string): void;
|
|
79
|
-
setModel(modelName: string, providerName: string): void;
|
|
80
|
-
setRecentSessions(sessions: RecentSession[]): void;
|
|
81
|
-
setLspServers(servers: LspServerInfo[] | null): void;
|
|
82
54
|
render(termWidth: number): readonly string[];
|
|
83
55
|
}
|
|
84
56
|
/** Block-grid brand mark shared by the welcome and setup surfaces. */
|
|
@@ -3,7 +3,7 @@ export declare const SETUP_SPLASH_MS = 2600;
|
|
|
3
3
|
export declare const SETUP_TICK_MS = 33;
|
|
4
4
|
export declare function renderStarfield(width: number, height: number, frame: number): string[];
|
|
5
5
|
/**
|
|
6
|
-
* Animated setup splash, in the spirit of the
|
|
6
|
+
* Animated setup splash, in the spirit of the Zeta visual identity: the brand ζ
|
|
7
7
|
* mark rendered with the live diagonal gradient + shine sweep, rising out of a
|
|
8
8
|
* rippling, gradient-lit water surface, under a faint twinkling starfield. The
|
|
9
9
|
* mark and water share one continuous gradient so the sweep reads across the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@linxiraos/pi-tui",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.1",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://linxira-os.github.io/zeta/",
|
|
7
7
|
"author": "Linxira-OS",
|
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@linxiraos/pi-omptype": "1.2.
|
|
42
|
-
"@linxiraos/pi-agent-core": "1.2.
|
|
43
|
-
"@linxiraos/pi-ai": "1.2.
|
|
44
|
-
"@linxiraos/pi-catalog": "1.2.
|
|
45
|
-
"@linxiraos/pi-natives": "1.2.
|
|
46
|
-
"@linxiraos/pi-utils": "1.2.
|
|
47
|
-
"@linxiraos/pi-wire": "1.2.
|
|
48
|
-
"@linxiraos/pi-snapcompact": "1.2.
|
|
41
|
+
"@linxiraos/pi-omptype": "1.2.1",
|
|
42
|
+
"@linxiraos/pi-agent-core": "1.2.1",
|
|
43
|
+
"@linxiraos/pi-ai": "1.2.1",
|
|
44
|
+
"@linxiraos/pi-catalog": "1.2.1",
|
|
45
|
+
"@linxiraos/pi-natives": "1.2.1",
|
|
46
|
+
"@linxiraos/pi-utils": "1.2.1",
|
|
47
|
+
"@linxiraos/pi-wire": "1.2.1",
|
|
48
|
+
"@linxiraos/pi-snapcompact": "1.2.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -1211,7 +1211,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
1211
1211
|
[span("Finished, parked, and killed subagents remain with the session that created them.", "dim")],
|
|
1212
1212
|
{ wrap: "word" },
|
|
1213
1213
|
),
|
|
1214
|
-
text([span("Resume that session with
|
|
1214
|
+
text([span("Resume that session with zetacode --continue, or spawn a task here.", "dim")], {
|
|
1215
1215
|
wrap: "word",
|
|
1216
1216
|
}),
|
|
1217
1217
|
],
|
|
@@ -1857,7 +1857,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
1857
1857
|
const emptyState = [
|
|
1858
1858
|
`${theme.fg("muted", theme.status.shadowed)} ${theme.bold("No agents in this session")}`,
|
|
1859
1859
|
theme.fg("dim", "Finished, parked, and killed subagents remain with the session that created them."),
|
|
1860
|
-
theme.fg("dim", "Resume that session with
|
|
1860
|
+
theme.fg("dim", "Resume that session with zetacode --continue, or spawn a task here."),
|
|
1861
1861
|
];
|
|
1862
1862
|
for (const line of emptyState.slice(0, budget)) {
|
|
1863
1863
|
lines.push(line);
|
|
@@ -307,9 +307,15 @@ export class PluginListComponent extends OverlayPanel {
|
|
|
307
307
|
if (entries.length === 0) {
|
|
308
308
|
this.addChild(new Text(theme.fg("muted", "No plugins installed"), 0, 0));
|
|
309
309
|
this.addChild(new Spacer(1));
|
|
310
|
-
this.addChild(new Text(theme.fg("dim", "Install npm plugins: zeta-c plugin install <package>"), 0, 0));
|
|
311
310
|
this.addChild(
|
|
312
|
-
new Text(theme.fg("dim", "Install
|
|
311
|
+
new Text(theme.fg("dim", "Install npm plugins: zetacode plugin install <package>"), 0, 0),
|
|
312
|
+
);
|
|
313
|
+
this.addChild(
|
|
314
|
+
new Text(
|
|
315
|
+
theme.fg("dim", "Install marketplace plugins: zetacode plugin install <name>@<marketplace>"),
|
|
316
|
+
0,
|
|
317
|
+
0,
|
|
318
|
+
),
|
|
313
319
|
);
|
|
314
320
|
this.addChild(new Spacer(1));
|
|
315
321
|
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
* (`~/.zeta/agent/cache/composer.db`).
|
|
4
4
|
*
|
|
5
5
|
* Each row is one JSON payload keyed by project (the resolved cwd) and kind.
|
|
6
|
-
* Settings-derived kinds (theme/composer preferences,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* rows stay per project.
|
|
6
|
+
* Settings-derived kinds (theme/composer preferences, status-bar inputs) are
|
|
7
|
+
* also written under the empty project, so a folder that never ran zeta-c still
|
|
8
|
+
* paints with the user's theme and status bar: those are rarely
|
|
9
|
+
* project-specific, and path/branch render live.
|
|
11
10
|
*
|
|
12
11
|
* ```text
|
|
13
12
|
* entries (project TEXT, kind TEXT, value TEXT JSON, PRIMARY KEY (project, kind))
|
|
@@ -25,7 +24,6 @@ import * as logger from "@linxiraos/pi-utils/logger";
|
|
|
25
24
|
import * as postmortem from "@linxiraos/pi-utils/postmortem";
|
|
26
25
|
import { openSqliteDatabaseSync } from "@linxiraos/pi-utils/sqlite";
|
|
27
26
|
import { isRecord } from "@linxiraos/pi-utils/type-guards";
|
|
28
|
-
import type { LspServerInfo, RecentSession } from "./welcome";
|
|
29
27
|
import type { ComposerPreferences, ComposerStatusCache } from "./composer";
|
|
30
28
|
import { readStatusLineStartupData } from "../status-line/startup";
|
|
31
29
|
import type { SymbolPreset } from "../theme/theme";
|
package/src/prompt/composer.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { postmortem } from "@linxiraos/pi-utils";
|
|
|
22
22
|
import { CustomEditor } from "./custom-editor";
|
|
23
23
|
import type { WordCompletionMethod } from "./word-completion";
|
|
24
24
|
import { type AnimationFrame, TranscriptContainer } from "../chrome/transcript-container";
|
|
25
|
-
import { WelcomeComponent
|
|
25
|
+
import { WelcomeComponent } from "./welcome";
|
|
26
26
|
import { ensureThemeSync, getEditorTheme, theme } from "../theme/theme";
|
|
27
27
|
|
|
28
28
|
const DOUBLE_INTERRUPT_MS = 500;
|
|
@@ -58,11 +58,6 @@ export const COMPOSER_DEFAULTS: ComposerPreferences = {
|
|
|
58
58
|
/** Welcome data that can be supplied initially or patched as startup resolves it. */
|
|
59
59
|
export interface ComposerWelcomeUpdate {
|
|
60
60
|
readonly version?: string;
|
|
61
|
-
readonly modelName?: string;
|
|
62
|
-
readonly providerName?: string;
|
|
63
|
-
readonly recentSessions?: readonly RecentSession[];
|
|
64
|
-
/** Detected project servers; `null` means LSP is disabled and hides the welcome section. */
|
|
65
|
-
readonly lspServers?: readonly LspServerInfo[] | null;
|
|
66
61
|
}
|
|
67
62
|
|
|
68
63
|
/**
|
|
@@ -215,10 +210,6 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
215
210
|
#preferences: ComposerPreferences;
|
|
216
211
|
#welcome: WelcomeComponent | undefined;
|
|
217
212
|
#version = "";
|
|
218
|
-
#modelName = "";
|
|
219
|
-
#providerName = "";
|
|
220
|
-
#recentSessions: RecentSession[] = [];
|
|
221
|
-
#lspServers: LspServerInfo[] | null = [];
|
|
222
213
|
#headerBefore: readonly Component[] = [];
|
|
223
214
|
#headerAfter: readonly Component[] = [];
|
|
224
215
|
#runtimeChildren: readonly Component[] = [];
|
|
@@ -894,7 +885,6 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
894
885
|
const welcome = this.#welcome;
|
|
895
886
|
if (!welcome) return;
|
|
896
887
|
if (update.version !== undefined) welcome.setVersion(this.#version);
|
|
897
|
-
welcome.setModel(this.#modelName, this.#providerName);
|
|
898
888
|
this.ui.requestRender();
|
|
899
889
|
}
|
|
900
890
|
|
|
@@ -976,20 +966,10 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
976
966
|
|
|
977
967
|
#applyWelcomeUpdate(update: ComposerWelcomeUpdate): void {
|
|
978
968
|
if (update.version !== undefined) this.#version = update.version;
|
|
979
|
-
if (update.modelName !== undefined || update.providerName !== undefined) {
|
|
980
|
-
this.#modelName = update.modelName ?? this.#modelName;
|
|
981
|
-
this.#providerName = update.providerName ?? this.#providerName;
|
|
982
|
-
}
|
|
983
969
|
}
|
|
984
970
|
|
|
985
971
|
#ensureWelcome(): void {
|
|
986
|
-
this.#welcome ??= new WelcomeComponent(
|
|
987
|
-
this.#version,
|
|
988
|
-
this.#modelName,
|
|
989
|
-
this.#providerName,
|
|
990
|
-
this.#recentSessions,
|
|
991
|
-
this.#lspServers,
|
|
992
|
-
);
|
|
972
|
+
this.#welcome ??= new WelcomeComponent(this.#version);
|
|
993
973
|
}
|
|
994
974
|
|
|
995
975
|
#rebuildHeader(): void {
|
package/src/prompt/welcome.ts
CHANGED
|
@@ -2,57 +2,73 @@ import { APP_NAME } from "@linxiraos/pi-utils/dirs";
|
|
|
2
2
|
import type { TspSpan } from "@linxiraos/pi-wire";
|
|
3
3
|
import { formatDoubleTap, formatKeyHint, formatKeyHints, type KeyName } from "../app-keybindings";
|
|
4
4
|
import { editorKey } from "../chrome/keybinding-hints";
|
|
5
|
+
import { tuiText } from "../i18n";
|
|
5
6
|
import { getKeybindings, type Keybinding } from "../keybindings";
|
|
6
|
-
import { card, col,
|
|
7
|
-
import type { DescribeContext, NativeChild, NativeNode
|
|
8
|
-
import {
|
|
9
|
-
import { compactText, plainLine, plainText } from "../native/spans";
|
|
10
|
-
|
|
7
|
+
import { card, col, keyed, node, row, span, text } from "../native/describe";
|
|
8
|
+
import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
|
|
9
|
+
import { compactText, plainText } from "../native/spans";
|
|
11
10
|
import { isNativeRendering } from "../native/state";
|
|
12
11
|
import { isHyperlinkEnabled, urlHyperlink, urlLinkSpan } from "../render/hyperlink";
|
|
13
12
|
import { TERMINAL } from "../terminal-capabilities";
|
|
14
13
|
import { theme } from "../theme/theme";
|
|
15
14
|
import type { Component } from "../tui";
|
|
16
|
-
import { padding, replaceTabs,
|
|
15
|
+
import { padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
17
16
|
import tipsText from "./tips.txt" with { type: "text" };
|
|
18
17
|
|
|
19
18
|
/** Leading marker for a tip that pitches Tern: shown only while no TSP surface
|
|
20
19
|
* is live, since a live one means the user is already in Tern. */
|
|
21
20
|
const NO_TERN_TIP_MARKER = /^\[NO-TERN\]\s*/;
|
|
22
21
|
|
|
23
|
-
/** tips.txt lines embedded at build time; blanks dropped. Key placeholders
|
|
24
|
-
* (see {@link expandTipKeys}) stay raw until render time. */
|
|
25
|
-
const TIP_LINES: readonly string[] = tipsText
|
|
26
|
-
.split("\n")
|
|
27
|
-
.map(line => line.trim())
|
|
28
|
-
.filter(line => line.length > 0);
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Fixed number of session rows in the welcome box so its height stays stable
|
|
32
|
-
* across recent-session updates.
|
|
33
|
-
*/
|
|
34
|
-
export const WELCOME_SESSION_SLOTS = 4;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Fixed number of LSP-server rows, for the same reason. Overflow is sliced so
|
|
38
|
-
* the box height is constant regardless of how many servers a project has.
|
|
39
|
-
*/
|
|
40
|
-
export const WELCOME_LSP_SLOTS = 4;
|
|
41
|
-
|
|
42
|
-
/** Tips for the classic renderer: every tip, {@link NO_TERN_TIP_MARKER} stripped. */
|
|
43
|
-
const TIPS: readonly string[] = TIP_LINES.map(line => line.replace(NO_TERN_TIP_MARKER, ""));
|
|
44
|
-
|
|
45
|
-
/** Tips for a live TSP surface: those marked {@link NO_TERN_TIP_MARKER} dropped. */
|
|
46
|
-
const TSP_TIPS: readonly string[] = TIP_LINES.filter(line => !NO_TERN_TIP_MARKER.test(line));
|
|
47
|
-
|
|
48
22
|
/** Trailing marker that flags a tip as a "what's new" callout. Stripped before
|
|
49
23
|
* wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT}
|
|
50
24
|
* painted as a shimmering rainbow. Non-global so `.test` stays stateless. */
|
|
51
25
|
const NEW_TIP_MARKER = /\s*\[NEW\]\s*$/;
|
|
52
26
|
|
|
27
|
+
/** A tips.txt line. The structural markers stay in the source line — they drive
|
|
28
|
+
* pool selection and the "[NEW]" weighting — while the user-visible text is
|
|
29
|
+
* resolved from the host catalogue under `tipN`, N = the 1-based line number. */
|
|
30
|
+
interface TipLine {
|
|
31
|
+
/** 1-based tips.txt line number; the catalogue key is `tip${line}`. */
|
|
32
|
+
readonly line: number;
|
|
33
|
+
/** Raw line, markers intact — what the weighted picker selects from. */
|
|
34
|
+
readonly raw: string;
|
|
35
|
+
/** English body with both markers stripped: the `tuiText` fallback. */
|
|
36
|
+
readonly english: string;
|
|
37
|
+
/** Pitches Tern; excluded from the TSP pool. */
|
|
38
|
+
readonly noTern: boolean;
|
|
39
|
+
/** "What's new" callout; carries the heavier selection weight. */
|
|
40
|
+
readonly isNew: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** tips.txt lines embedded at build time; blanks dropped. Key placeholders
|
|
44
|
+
* (see {@link expandTipKeys}) and markdown links stay raw until render time. */
|
|
45
|
+
const TIP_LINES: readonly TipLine[] = tipsText.split("\n").flatMap((line, index) => {
|
|
46
|
+
const raw = line.trim();
|
|
47
|
+
if (raw.length === 0) return [];
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
line: index + 1,
|
|
51
|
+
raw,
|
|
52
|
+
english: raw.replace(NO_TERN_TIP_MARKER, "").replace(NEW_TIP_MARKER, ""),
|
|
53
|
+
noTern: NO_TERN_TIP_MARKER.test(raw),
|
|
54
|
+
isNew: NEW_TIP_MARKER.test(raw),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Classic pool: every tip's raw line, so {@link pickWeightedTip} can still read
|
|
60
|
+
* the trailing {@link NEW_TIP_MARKER} for its selection weight. */
|
|
61
|
+
const TIPS: readonly string[] = TIP_LINES.map(tip => tip.raw);
|
|
62
|
+
|
|
63
|
+
/** TSP pool: those marked {@link NO_TERN_TIP_MARKER} dropped. */
|
|
64
|
+
const TSP_TIPS: readonly string[] = TIP_LINES.filter(tip => !tip.noTern).map(tip => tip.raw);
|
|
65
|
+
|
|
53
66
|
/** Visible text rendered in place of {@link NEW_TIP_MARKER}. */
|
|
54
67
|
const NEW_TAG_TEXT = "NEW!";
|
|
55
68
|
|
|
69
|
+
/** Inline English for the nerdfont nudge tip (`welcomeNerdFontJoke`). */
|
|
70
|
+
const NERDFONT_TIP = "Please use nerdfont 😭.";
|
|
71
|
+
|
|
56
72
|
/** Milliseconds for one full hue rotation of the rainbow "NEW!" tag. */
|
|
57
73
|
const NEW_GLOW_PERIOD_MS = 1500;
|
|
58
74
|
|
|
@@ -75,16 +91,33 @@ export function pickWeightedTip(tips: readonly string[], r: number): string {
|
|
|
75
91
|
return tips[tips.length - 1] ?? "";
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
/** A tip resolved through the host catalogue: the localised body plus the
|
|
95
|
+
* structural "[NEW]" flag, which lives in tips.txt rather than the translated
|
|
96
|
+
* text (so a translation never has to carry the marker). */
|
|
97
|
+
interface ResolvedTip {
|
|
98
|
+
readonly text: string;
|
|
99
|
+
readonly isNew: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Localise a picked tip. `raw` is a {@link TIPS}/{@link TSP_TIPS} member (or the
|
|
103
|
+
* nerdfont nudge); the catalogue is consulted live, so `/language` applies. */
|
|
104
|
+
function resolveTip(raw: string): ResolvedTip {
|
|
105
|
+
if (raw === NERDFONT_TIP) return { text: tuiText("welcomeNerdFontJoke", NERDFONT_TIP), isNew: false };
|
|
106
|
+
const entry = TIP_LINES.find(tip => tip.raw === raw);
|
|
107
|
+
if (entry === undefined) return { text: raw, isNew: NEW_TIP_MARKER.test(raw) };
|
|
108
|
+
return { text: tuiText(`tip${entry.line}`, entry.english), isNew: entry.isNew };
|
|
109
|
+
}
|
|
110
|
+
|
|
78
111
|
type ColorEncoding = "ansi-16m" | "ansi-256";
|
|
79
112
|
|
|
80
|
-
/** Paint each glyph of
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
function renderNewTag(phase: number, encoding: ColorEncoding): string {
|
|
113
|
+
/** Paint each glyph of `tagText` on a moving HSL rainbow. `phase` rotates the
|
|
114
|
+
* hue offset cyclically; successive renders with increasing phase shimmer,
|
|
115
|
+
* while a fixed phase yields a still rainbow. */
|
|
116
|
+
function renderNewTag(tagText: string, phase: number, encoding: ColorEncoding): string {
|
|
84
117
|
const bold = "\x1b[1m";
|
|
85
118
|
const reset = "\x1b[0m";
|
|
86
119
|
const wrapped = ((phase % 1) + 1) % 1;
|
|
87
|
-
const chars = [...
|
|
120
|
+
const chars = [...tagText];
|
|
88
121
|
let out = bold;
|
|
89
122
|
let prev = "";
|
|
90
123
|
for (let i = 0; i < chars.length; i++) {
|
|
@@ -158,67 +191,58 @@ function tipSpans(tip: string): TspSpan[] {
|
|
|
158
191
|
}
|
|
159
192
|
|
|
160
193
|
/**
|
|
161
|
-
* The welcome tip as lines of at most `width` columns:
|
|
162
|
-
* wrapped together, with no indent, so the banner can center
|
|
163
|
-
*
|
|
194
|
+
* The welcome tip as lines of at most `width` columns: the `welcomeTipLabel`
|
|
195
|
+
* label and the body wrapped together, with no indent, so the banner can center
|
|
196
|
+
* each line. `isNew` drives the rainbow "welcomeNewTag" suffix; it is passed in
|
|
197
|
+
* because the marker lives in tips.txt, not in the resolved body. `[]` when
|
|
198
|
+
* `width` leaves no room for a useful line.
|
|
164
199
|
*/
|
|
165
|
-
export function renderWelcomeTip(tip: string, width: number, phase = 0): string[] {
|
|
166
|
-
const label = "Tip: ";
|
|
167
|
-
|
|
168
|
-
const bodyBudget = width - labelWidth;
|
|
169
|
-
if (bodyBudget < 8) return [];
|
|
200
|
+
export function renderWelcomeTip(tip: string, width: number, phase = 0, isNew = NEW_TIP_MARKER.test(tip)): string[] {
|
|
201
|
+
const label = tuiText("welcomeTipLabel", "Tip: ");
|
|
202
|
+
if (width - visibleWidth(label) < 8) return [];
|
|
170
203
|
|
|
171
|
-
const isNew = NEW_TIP_MARKER.test(tip);
|
|
172
204
|
const body = linkTipText(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip));
|
|
173
205
|
|
|
174
|
-
|
|
175
|
-
|
|
206
|
+
// Trailing spaces left by the wrap would count toward the width the banner centers.
|
|
207
|
+
const wrapped = wrapTextWithAnsi(replaceTabs(`${label}${body}`), width).map(line => line.trimEnd());
|
|
208
|
+
if (wrapped.length === 0) return [];
|
|
176
209
|
|
|
177
210
|
// Pull both colors from the active theme so the line stays readable on light
|
|
178
211
|
// themes; the previous hardcoded `#b48cff` / `#9ccfff` pastels (plus a manual
|
|
179
212
|
// `\x1b[2m` dim on the body) dropped to ~1.5:1 contrast on a white background.
|
|
180
|
-
const continuationIndent = padding(labelWidth);
|
|
181
213
|
const styledLabel = theme.fg("customMessageLabel", label);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
214
|
+
const lines = wrapped.map((line, index) =>
|
|
215
|
+
theme.italic(
|
|
216
|
+
index === 0 && line.startsWith(label)
|
|
217
|
+
? `${styledLabel}${theme.fg("muted", line.slice(label.length))}`
|
|
218
|
+
: theme.fg("muted", line),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
188
221
|
|
|
189
222
|
if (isNew) {
|
|
190
|
-
// Append the rainbow tag to the final
|
|
191
|
-
//
|
|
192
|
-
// styled glyphs never overflow or reflow the wrapped body.
|
|
223
|
+
// Append the rainbow tag to the final line when it fits; otherwise give it
|
|
224
|
+
// a line of its own so the styled glyphs never overflow the width.
|
|
193
225
|
const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
|
|
194
|
-
const
|
|
195
|
-
const
|
|
226
|
+
const tagText = tuiText("welcomeNewTag", NEW_TAG_TEXT);
|
|
227
|
+
const tag = renderNewTag(tagText, phase, encoding);
|
|
228
|
+
const tagWidth = 1 + visibleWidth(tagText); // 1 = space separator
|
|
196
229
|
const lastLine = lines[lines.length - 1];
|
|
197
230
|
if (lastLine !== undefined && visibleWidth(lastLine) + tagWidth <= width) {
|
|
198
231
|
lines[lines.length - 1] = `${lastLine} ${tag}`;
|
|
199
232
|
} else {
|
|
200
|
-
lines.push(
|
|
233
|
+
lines.push(tag);
|
|
201
234
|
}
|
|
202
235
|
}
|
|
203
236
|
|
|
204
237
|
return lines;
|
|
205
238
|
}
|
|
206
239
|
|
|
207
|
-
export interface RecentSession {
|
|
208
|
-
name: string;
|
|
209
|
-
timeAgo: string;
|
|
210
|
-
/** Session file; a native click on the row resumes it. */
|
|
211
|
-
path?: string;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export interface LspServerInfo {
|
|
215
|
-
name: string;
|
|
216
|
-
status: "ready" | "error" | "connecting" | "available";
|
|
217
|
-
fileTypes: string[];
|
|
218
|
-
}
|
|
219
|
-
|
|
220
240
|
/**
|
|
221
|
-
*
|
|
241
|
+
* The session's welcome banner. In a terminal: the gradient logo beside the
|
|
242
|
+
* `zeta` wordmark with the version under it (the logo alone when the lockup does
|
|
243
|
+
* not fit) and the tip of the session (dropped below {@link TIP_MIN_COLUMNS}
|
|
244
|
+
* columns). Natively: a card with the same logo, wordmark, version and tip
|
|
245
|
+
* ({@link WelcomeComponent.describe}).
|
|
222
246
|
*/
|
|
223
247
|
export class WelcomeComponent implements Component {
|
|
224
248
|
#animStart: number | null = null;
|
|
@@ -233,24 +257,20 @@ export class WelcomeComponent implements Component {
|
|
|
233
257
|
#tipRoll: number | undefined;
|
|
234
258
|
// Render cache: the welcome box is the first transcript-area component, so
|
|
235
259
|
// returning a stable array reference keeps the whole frame prefix stable.
|
|
236
|
-
// Bypassed while the intro animation runs (every frame differs).
|
|
260
|
+
// Bypassed while the intro animation runs (every frame differs). The
|
|
261
|
+
// `#cachedLang` sentinel retires the cache when `/language` swaps the
|
|
262
|
+
// catalogue, so a live switch repaints instead of serving stale text.
|
|
237
263
|
#cachedWidth = -1;
|
|
238
264
|
#cachedLines: string[] | undefined;
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
private modelName: string = "",
|
|
244
|
-
private providerName: string = "",
|
|
245
|
-
private recentSessions: RecentSession[] = [],
|
|
246
|
-
/** Detected project servers; `null` means LSP is disabled and hides the section. */
|
|
247
|
-
private lspServers: LspServerInfo[] | null = [],
|
|
248
|
-
) {}
|
|
265
|
+
#cachedLang = "";
|
|
266
|
+
#native: { lang: string; tip: string | undefined; node: NativeNode } | undefined;
|
|
267
|
+
|
|
268
|
+
constructor(private version: string) {}
|
|
249
269
|
get tip(): string | undefined {
|
|
250
270
|
this.#nagRoll ??= Math.random();
|
|
251
271
|
this.#tipRoll ??= Math.random();
|
|
252
272
|
if (theme.getSymbolPreset() === "unicode" && this.#nagRoll < 0.1) {
|
|
253
|
-
return
|
|
273
|
+
return NERDFONT_TIP;
|
|
254
274
|
}
|
|
255
275
|
return pickWeightedTip(isNativeRendering() ? TSP_TIPS : TIPS, this.#tipRoll) || undefined;
|
|
256
276
|
}
|
|
@@ -262,25 +282,23 @@ export class WelcomeComponent implements Component {
|
|
|
262
282
|
}
|
|
263
283
|
|
|
264
284
|
/**
|
|
265
|
-
* A `card` (`zeta.welcome`)
|
|
266
|
-
* (`zeta.welcome.
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* carry the look (gradient logo, type scale, column hairline); a "[NEW]" tip
|
|
285
|
+
* A `card` (`zeta.welcome`) mirroring the terminal banner: the lockup
|
|
286
|
+
* (`zeta.welcome.lockup`: the terminal's builtin `zeta` mark, which it animates,
|
|
287
|
+
* beside the wordmark with the version under it) and the tip of the session.
|
|
288
|
+
* Roles carry the look (gradient logo, type scale); a "[NEW]" tip
|
|
270
289
|
* carries a terminal-clocked shimmering tag.
|
|
271
290
|
*/
|
|
272
291
|
describe(_cx: DescribeContext): NativeNode {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
//
|
|
292
|
+
const lang = tuiText("welcomeBack", "Welcome back!");
|
|
293
|
+
const rawTip = this.tip;
|
|
294
|
+
const tip = rawTip === undefined ? undefined : resolveTip(rawTip);
|
|
295
|
+
if (this.#native && this.#native.lang === lang && this.#native.tip === tip?.text) return this.#native.node;
|
|
296
|
+
// Brand lines are short and fixed; never wrap or truncate them.
|
|
278
297
|
const art = (spans: readonly TspSpan[], role: string): NativeNode =>
|
|
279
298
|
keyed(text(spans, { wrap: "none", role }), role);
|
|
280
|
-
const
|
|
281
|
-
|
|
299
|
+
const lockupRow = keyed(
|
|
300
|
+
row(
|
|
282
301
|
[
|
|
283
|
-
art([span("Welcome back!", "strong")], "zeta.welcome.greeting"),
|
|
284
302
|
node(
|
|
285
303
|
"image",
|
|
286
304
|
{
|
|
@@ -292,115 +310,38 @@ export class WelcomeComponent implements Component {
|
|
|
292
310
|
undefined,
|
|
293
311
|
"logo",
|
|
294
312
|
),
|
|
295
|
-
],
|
|
296
|
-
{ align: "center", role: "zeta.welcome.brand" },
|
|
297
|
-
),
|
|
298
|
-
"brand",
|
|
299
|
-
);
|
|
300
|
-
const section = (key: string, label: string, rows: readonly NativeChild[]): NativeNode =>
|
|
301
|
-
keyed(
|
|
302
|
-
col([line([span(label, "dim")], "zeta.welcome.heading"), ...rows], {
|
|
303
|
-
gap: "xs",
|
|
304
|
-
role: `zeta.welcome.${key}`,
|
|
305
|
-
}),
|
|
306
|
-
key,
|
|
307
|
-
);
|
|
308
|
-
const shortcut = (key: string, label: string): NativeNode =>
|
|
309
|
-
keyed(row([kbd(key), line([span(label, "muted")])], { gap: "sm" }), label);
|
|
310
|
-
const info: NativeChild[] = [
|
|
311
|
-
line([span(this.version, "dim mono")], "zeta.welcome.version"),
|
|
312
|
-
section("tips", "Tips", [
|
|
313
|
-
shortcut("#", "prompt actions"),
|
|
314
|
-
shortcut("/", "commands"),
|
|
315
|
-
shortcut("!", "run bash"),
|
|
316
|
-
shortcut("$", "run python"),
|
|
317
|
-
]),
|
|
318
|
-
];
|
|
319
|
-
if (this.lspServers !== null) {
|
|
320
|
-
const lsp: NativeChild[] = [];
|
|
321
|
-
if (this.lspServers.length === 0) lsp.push(line([span("No LSP servers", "dim")], undefined, "none"));
|
|
322
|
-
for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
|
|
323
|
-
const [symbol, token] =
|
|
324
|
-
server.status === "ready"
|
|
325
|
-
? (["status.enabled", "success"] as const)
|
|
326
|
-
: server.status === "available"
|
|
327
|
-
? (["status.enabled", "dim"] as const)
|
|
328
|
-
: server.status === "connecting"
|
|
329
|
-
? (["status.pending", "muted"] as const)
|
|
330
|
-
: (["status.error", "error"] as const);
|
|
331
|
-
lsp.push(
|
|
332
313
|
keyed(
|
|
333
|
-
|
|
314
|
+
col(
|
|
334
315
|
[
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
...server.fileTypes
|
|
338
|
-
.slice(0, 3)
|
|
339
|
-
.map(type => node("badge", { text: type, role: "zeta.welcome.lsp-type" }, undefined, type)),
|
|
316
|
+
art([span("Z e t a", "strong")], "zeta.welcome.wordmark"),
|
|
317
|
+
art([span(`v${this.version}`, "dim mono")], "zeta.welcome.version"),
|
|
340
318
|
],
|
|
341
|
-
{
|
|
319
|
+
{ role: "zeta.welcome.mark" },
|
|
342
320
|
),
|
|
343
|
-
|
|
321
|
+
"mark",
|
|
344
322
|
),
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
info.push(section("lsp", "LSP servers", lsp));
|
|
348
|
-
}
|
|
349
|
-
const recents: NativeChild[] = [];
|
|
350
|
-
if (this.recentSessions.length === 0) recents.push(line([span("No recent sessions", "dim")], undefined, "none"));
|
|
351
|
-
for (const [index, session] of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS).entries()) {
|
|
352
|
-
recents.push(
|
|
353
|
-
keyed(
|
|
354
|
-
row(
|
|
355
|
-
[
|
|
356
|
-
line([span(plainLine(session.name))], "zeta.welcome.session"),
|
|
357
|
-
line([span(session.timeAgo, "dim")], "zeta.welcome.age"),
|
|
358
|
-
],
|
|
359
|
-
{
|
|
360
|
-
gap: "md",
|
|
361
|
-
justify: "between",
|
|
362
|
-
role: "zeta.welcome.recent",
|
|
363
|
-
actions: session.path ? { click: "resume" } : undefined,
|
|
364
|
-
title: session.path ? `Resume ${plainLine(session.name)}` : undefined,
|
|
365
|
-
},
|
|
366
|
-
),
|
|
367
|
-
`s${index}`,
|
|
368
|
-
),
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
info.push(section("recents", "Recent sessions", recents));
|
|
372
|
-
const body: NativeChild[] = [
|
|
373
|
-
keyed(
|
|
374
|
-
row([brand, keyed(col(info, { gap: "md", role: "zeta.welcome.info" }), "info")], {
|
|
375
|
-
align: "start",
|
|
376
|
-
wrap: true,
|
|
377
|
-
role: "zeta.welcome.grid",
|
|
378
|
-
}),
|
|
379
|
-
"grid",
|
|
323
|
+
],
|
|
324
|
+
{ align: "center", gap: "md", role: "zeta.welcome.lockup" },
|
|
380
325
|
),
|
|
381
|
-
|
|
326
|
+
"lockup",
|
|
327
|
+
);
|
|
328
|
+
const body: NativeChild[] = [lockupRow];
|
|
382
329
|
if (tip) {
|
|
383
|
-
const
|
|
384
|
-
const tipText = compactText(tipSpans(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip)));
|
|
330
|
+
const tipText = compactText(tipSpans(expandTipKeys(tip.text)));
|
|
385
331
|
const tipRow: NativeChild[] = [
|
|
386
332
|
node("icon", { name: "lightbulb", role: "zeta.welcome.tip-icon" }),
|
|
387
333
|
text(tipText, { wrap: "word", role: "zeta.welcome.tip-text" }),
|
|
388
334
|
];
|
|
389
|
-
if (isNew)
|
|
335
|
+
if (tip.isNew) {
|
|
336
|
+
tipRow.push(node("shimmer", { text: tuiText("welcomeNewTag", NEW_TAG_TEXT), role: "zeta.welcome.new" }));
|
|
337
|
+
}
|
|
390
338
|
body.push(node("row", { gap: "sm", align: "start", role: "zeta.welcome.tip" }, tipRow, "tip"));
|
|
391
339
|
}
|
|
392
|
-
// No head row or chevron: the card is the hero; the version sits
|
|
340
|
+
// No head row or chevron: the card is the hero; the version sits under the wordmark.
|
|
393
341
|
const described = card({ role: "zeta.welcome" }, body);
|
|
394
|
-
this.#native = { tip, node: described };
|
|
342
|
+
this.#native = { lang, tip: tip?.text, node: described };
|
|
395
343
|
return described;
|
|
396
344
|
}
|
|
397
|
-
/** A click on a recent session resumes it. */
|
|
398
|
-
handleNativeEvent(event: NativeUiEvent): void {
|
|
399
|
-
if (event.type !== "action" || event.act !== "resume") return;
|
|
400
|
-
const index = Number(/\/s(\d+)$/.exec(event.key)?.[1]);
|
|
401
|
-
const path = this.recentSessions[index]?.path;
|
|
402
|
-
if (path) runTranscriptAction({ act: "resume", path });
|
|
403
|
-
}
|
|
404
345
|
|
|
405
346
|
/** The intro keeps the welcome block mutable; settling lets it retire to history. */
|
|
406
347
|
isTranscriptBlockFinalized(): boolean {
|
|
@@ -468,25 +409,10 @@ export class WelcomeComponent implements Component {
|
|
|
468
409
|
this.invalidate();
|
|
469
410
|
}
|
|
470
411
|
|
|
471
|
-
setModel(modelName: string, providerName: string): void {
|
|
472
|
-
this.modelName = modelName;
|
|
473
|
-
this.providerName = providerName;
|
|
474
|
-
this.invalidate();
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
setRecentSessions(sessions: RecentSession[]): void {
|
|
478
|
-
this.recentSessions = sessions;
|
|
479
|
-
this.invalidate();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
setLspServers(servers: LspServerInfo[] | null): void {
|
|
483
|
-
this.lspServers = servers;
|
|
484
|
-
this.invalidate();
|
|
485
|
-
}
|
|
486
|
-
|
|
487
412
|
render(termWidth: number): readonly string[] {
|
|
488
413
|
const animating = this.#animStart != null;
|
|
489
|
-
|
|
414
|
+
const lang = tuiText("welcomeBack", "Welcome back!");
|
|
415
|
+
if (!animating && this.#cachedLines && this.#cachedWidth === termWidth && this.#cachedLang === lang) {
|
|
490
416
|
return this.#cachedLines;
|
|
491
417
|
}
|
|
492
418
|
const lines = this.#renderLines(termWidth);
|
|
@@ -496,223 +422,39 @@ export class WelcomeComponent implements Component {
|
|
|
496
422
|
} else {
|
|
497
423
|
this.#cachedLines = lines;
|
|
498
424
|
this.#cachedWidth = termWidth;
|
|
425
|
+
this.#cachedLang = lang;
|
|
499
426
|
}
|
|
500
427
|
return lines;
|
|
501
428
|
}
|
|
502
429
|
|
|
503
430
|
#renderLines(termWidth: number): string[] {
|
|
504
|
-
//
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
// Dynamic model/provider labels are truncated inside the fixed column.
|
|
515
|
-
// Letting them influence the responsive breakpoint changes the box height
|
|
516
|
-
// when authoritative session data replaces the empty prepaint labels.
|
|
517
|
-
const leftMinContentWidth = Math.max(minLeftCol, visibleWidth("Welcome back!"));
|
|
518
|
-
const desiredLeftCol = Math.max(
|
|
519
|
-
Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35))),
|
|
520
|
-
leftMinContentWidth,
|
|
521
|
-
);
|
|
522
|
-
const dualLeftCol =
|
|
523
|
-
dualContentWidth >= minRightCol + 1
|
|
524
|
-
? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
|
|
525
|
-
: Math.max(1, dualContentWidth - 1);
|
|
526
|
-
const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
|
|
527
|
-
const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
|
|
528
|
-
const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
|
|
529
|
-
const rightCol = showRightColumn ? dualRightCol : 0;
|
|
530
|
-
|
|
531
|
-
// Logo: pick a frame from the intro animation if active, else the resting frame.
|
|
532
|
-
const logoColored = this.#currentLogoFrame();
|
|
533
|
-
|
|
534
|
-
// Left column - centered content
|
|
535
|
-
const leftLines = [
|
|
536
|
-
"",
|
|
537
|
-
this.#centerText(theme.bold("Welcome back!"), leftCol),
|
|
538
|
-
"",
|
|
539
|
-
...logoColored.map(l => this.#centerText(l, leftCol)),
|
|
540
|
-
"",
|
|
541
|
-
this.#centerText(theme.fg("muted", this.modelName), leftCol),
|
|
542
|
-
this.#centerText(theme.fg("borderMuted", this.providerName), leftCol),
|
|
543
|
-
];
|
|
544
|
-
|
|
545
|
-
// Right column separator
|
|
546
|
-
const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
|
|
547
|
-
const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
|
|
548
|
-
|
|
549
|
-
// Recent sessions content
|
|
550
|
-
const sessionLines: string[] = [];
|
|
551
|
-
if (this.recentSessions.length === 0) {
|
|
552
|
-
sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
|
|
553
|
-
} else {
|
|
554
|
-
// Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
|
|
555
|
-
// so the relative time is never the part that gets truncated. The name
|
|
556
|
-
// absorbs whatever space is left.
|
|
557
|
-
const bulletPrefix = ` ${theme.md.bullet} `;
|
|
558
|
-
const prefixWidth = visibleWidth(bulletPrefix);
|
|
559
|
-
for (const session of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS)) {
|
|
560
|
-
const timeSuffixRaw = ` (${session.timeAgo})`;
|
|
561
|
-
const timeWidth = visibleWidth(timeSuffixRaw);
|
|
562
|
-
const nameBudget = Math.max(1, rightCol - prefixWidth - timeWidth);
|
|
563
|
-
const nameVis = visibleWidth(session.name);
|
|
564
|
-
const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
|
|
565
|
-
sessionLines.push(
|
|
566
|
-
`${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`,
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
// Pad to the fixed slot count so the box height doesn't depend on session count.
|
|
571
|
-
while (sessionLines.length < WELCOME_SESSION_SLOTS) {
|
|
572
|
-
sessionLines.push("");
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// Right column
|
|
576
|
-
const rightLines = [
|
|
577
|
-
` ${theme.bold(theme.fg("accent", "Tips"))}`,
|
|
578
|
-
` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
|
|
579
|
-
` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
|
|
580
|
-
` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
|
|
581
|
-
` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
|
|
582
|
-
...this.#renderLspSection(separator),
|
|
583
|
-
separator,
|
|
584
|
-
` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
|
|
585
|
-
...sessionLines,
|
|
586
|
-
"",
|
|
587
|
-
];
|
|
588
|
-
|
|
589
|
-
// Border characters (dim)
|
|
590
|
-
const hChar = theme.boxRound.horizontal;
|
|
591
|
-
const h = theme.fg("dim", hChar);
|
|
592
|
-
const v = theme.fg("dim", theme.boxRound.vertical);
|
|
593
|
-
const tl = theme.fg("dim", theme.boxRound.topLeft);
|
|
594
|
-
const tr = theme.fg("dim", theme.boxRound.topRight);
|
|
595
|
-
const bl = theme.fg("dim", theme.boxRound.bottomLeft);
|
|
596
|
-
const br = theme.fg("dim", theme.boxRound.bottomRight);
|
|
597
|
-
|
|
598
|
-
const lines: string[] = [];
|
|
599
|
-
|
|
600
|
-
// Top border with embedded title
|
|
601
|
-
const title = ` ${APP_NAME} v${this.version} `;
|
|
602
|
-
const titlePrefixRaw = hChar.repeat(3);
|
|
603
|
-
const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
|
|
604
|
-
const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
|
|
605
|
-
const titleSpace = boxWidth - 2;
|
|
606
|
-
if (titleVisLen >= titleSpace) {
|
|
607
|
-
lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
|
|
608
|
-
} else {
|
|
609
|
-
const afterTitle = titleSpace - titleVisLen;
|
|
610
|
-
lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Content rows
|
|
614
|
-
const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
|
|
615
|
-
for (let i = 0; i < maxRows; i++) {
|
|
616
|
-
const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
|
|
617
|
-
if (showRightColumn) {
|
|
618
|
-
const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
|
|
619
|
-
lines.push(v + left + v + right + v);
|
|
620
|
-
} else {
|
|
621
|
-
lines.push(v + left + v);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
// Bottom border
|
|
625
|
-
if (showRightColumn) {
|
|
626
|
-
lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br);
|
|
627
|
-
} else {
|
|
628
|
-
lines.push(bl + h.repeat(leftCol) + br);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Randomly picked tip, rendered directly beneath the box.
|
|
632
|
-
// The banner centers every tip line, so the tip drops out entirely on
|
|
633
|
-
// narrow boxes rather than wrapping into unreadable slivers.
|
|
634
|
-
if (termWidth >= 16) lines.push(...this.#renderTip(boxWidth));
|
|
635
|
-
|
|
431
|
+
// Content keeps a column clear on each side; everything centers in the full width.
|
|
432
|
+
const room = termWidth - 2;
|
|
433
|
+
if (room < 4) return [];
|
|
434
|
+
const logo = this.#currentLogoFrame();
|
|
435
|
+
const version = theme.fg("dim", `v${this.version}`);
|
|
436
|
+
const lockupWidth = LOGO_WIDTH + LOCKUP_GAP + Math.max(WORDMARK_WIDTH, visibleWidth(version));
|
|
437
|
+
const art = room >= lockupWidth ? lockup(logo, version) : room >= LOGO_WIDTH ? logo : [];
|
|
438
|
+
const lines = centerBlock(art, termWidth);
|
|
439
|
+
const tip = termWidth >= TIP_MIN_COLUMNS ? this.#renderTip(room) : [];
|
|
440
|
+
if (tip.length > 0) lines.push("", ...tip.flatMap(line => centerBlock([line], termWidth)));
|
|
636
441
|
return lines;
|
|
637
442
|
}
|
|
638
443
|
|
|
639
|
-
/** Right-column LSP rows padded to a fixed height; empty when LSP is disabled. */
|
|
640
|
-
#renderLspSection(separator: string): string[] {
|
|
641
|
-
if (this.lspServers === null) return [];
|
|
642
|
-
const lspLines: string[] = [];
|
|
643
|
-
if (this.lspServers.length === 0) {
|
|
644
|
-
lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
|
|
645
|
-
} else {
|
|
646
|
-
for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
|
|
647
|
-
const icon =
|
|
648
|
-
server.status === "ready"
|
|
649
|
-
? theme.styledSymbol("status.enabled", "success")
|
|
650
|
-
: server.status === "available"
|
|
651
|
-
? theme.styledSymbol("status.enabled", "dim")
|
|
652
|
-
: server.status === "connecting"
|
|
653
|
-
? theme.styledSymbol("status.pending", "muted")
|
|
654
|
-
: theme.styledSymbol("status.error", "error");
|
|
655
|
-
const exts = server.fileTypes.slice(0, 3).join(" ");
|
|
656
|
-
lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
// Pad to the fixed slot count so the box height doesn't depend on server count.
|
|
660
|
-
while (lspLines.length < WELCOME_LSP_SLOTS) {
|
|
661
|
-
lspLines.push("");
|
|
662
|
-
}
|
|
663
|
-
return [separator, ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`, ...lspLines];
|
|
664
|
-
}
|
|
665
|
-
|
|
666
444
|
/**
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* when no tip is available or the box is too narrow to be useful.
|
|
445
|
+
* The tip of the session wrapped to {@link TIP_MEASURE} columns (fewer when
|
|
446
|
+
* `room` is narrower); `[]` when there is none or no room for it.
|
|
670
447
|
*/
|
|
671
|
-
#renderTip(
|
|
672
|
-
const
|
|
673
|
-
if (!
|
|
674
|
-
// A trailing "[NEW]" marker paints an animated rainbow
|
|
675
|
-
//
|
|
676
|
-
//
|
|
448
|
+
#renderTip(room: number): string[] {
|
|
449
|
+
const raw = this.tip;
|
|
450
|
+
if (!raw) return [];
|
|
451
|
+
// A trailing "[NEW]" marker paints an animated rainbow tag. Derive its
|
|
452
|
+
// hue phase from wall-clock time so it shimmers across the welcome intro's
|
|
453
|
+
// re-render frames, then settles into a still rainbow once the banner
|
|
677
454
|
// caches its resting frame. Non-"[NEW]" tips ignore the phase entirely.
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
/** Center text within a given width */
|
|
683
|
-
#centerText(text: string, width: number): string {
|
|
684
|
-
const visLen = visibleWidth(text);
|
|
685
|
-
if (visLen >= width) {
|
|
686
|
-
return truncateToWidth(text, width);
|
|
687
|
-
}
|
|
688
|
-
const leftPad = Math.floor((width - visLen) / 2);
|
|
689
|
-
const rightPad = width - visLen - leftPad;
|
|
690
|
-
return padding(leftPad) + text + padding(rightPad);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/** Fit string to exact width with ANSI-aware truncation/padding */
|
|
694
|
-
#fitToWidth(str: string, width: number): string {
|
|
695
|
-
const visLen = visibleWidth(str);
|
|
696
|
-
if (visLen > width) {
|
|
697
|
-
const ellipsis = "…";
|
|
698
|
-
const ellipsisWidth = visibleWidth(ellipsis);
|
|
699
|
-
const maxWidth = Math.max(0, width - ellipsisWidth);
|
|
700
|
-
let truncated = "";
|
|
701
|
-
let currentWidth = 0;
|
|
702
|
-
let inEscape = false;
|
|
703
|
-
for (const char of str) {
|
|
704
|
-
if (char === "\x1b") inEscape = true;
|
|
705
|
-
if (inEscape) {
|
|
706
|
-
truncated += char;
|
|
707
|
-
if (char === "m") inEscape = false;
|
|
708
|
-
} else if (currentWidth < maxWidth) {
|
|
709
|
-
truncated += char;
|
|
710
|
-
currentWidth++;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
return `${truncated}${ellipsis}`;
|
|
714
|
-
}
|
|
715
|
-
return str + padding(width - visLen);
|
|
455
|
+
const tip = resolveTip(raw);
|
|
456
|
+
const phase = tip.isNew ? performance.now() / NEW_GLOW_PERIOD_MS : 0;
|
|
457
|
+
return renderWelcomeTip(tip.text, Math.min(room, TIP_MEASURE), phase, tip.isNew);
|
|
716
458
|
}
|
|
717
459
|
|
|
718
460
|
/** Pick the logo frame for the current intro phase, or the resting frame. */
|
|
@@ -725,7 +467,46 @@ export class WelcomeComponent implements Component {
|
|
|
725
467
|
}
|
|
726
468
|
|
|
727
469
|
/** Block-grid brand mark shared by the welcome and setup surfaces. */
|
|
728
|
-
export const ZETA_LOGO = ["
|
|
470
|
+
export const ZETA_LOGO = ["████████████", " ████████ ", " ████████ ", " ████████▒▒▒", "████████████"];
|
|
471
|
+
|
|
472
|
+
/** Columns of {@link ZETA_LOGO}. */
|
|
473
|
+
const LOGO_WIDTH = Math.max(...ZETA_LOGO.map(row => row.length));
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The `Zeta` wordmark in half-blocks, set beside {@link ZETA_LOGO} from its
|
|
477
|
+
* second row: the `Z` is a solid zig-zag closed by a light step, `e`/`a` sit on
|
|
478
|
+
* the x-height, the `t` ascender rises above it, and the version takes the
|
|
479
|
+
* fifth row.
|
|
480
|
+
*/
|
|
481
|
+
const WORDMARK = ["███▀ ▄ ", " ██ ███▄ ███ ▄██▄", "▄▄██ █▄▄ █▄ █▄▄█"];
|
|
482
|
+
|
|
483
|
+
/** Columns of {@link WORDMARK}. */
|
|
484
|
+
const WORDMARK_WIDTH = Math.max(...WORDMARK.map(row => row.length));
|
|
485
|
+
|
|
486
|
+
/** Columns between the logo and the wordmark. */
|
|
487
|
+
const LOCKUP_GAP = 4;
|
|
488
|
+
|
|
489
|
+
/** Widest a welcome tip wraps, so a long one stays a centered paragraph. */
|
|
490
|
+
const TIP_MEASURE = 72;
|
|
491
|
+
|
|
492
|
+
/** Narrowest terminal that still shows the tip; below it the banner is the logo alone. */
|
|
493
|
+
const TIP_MIN_COLUMNS = 50;
|
|
494
|
+
|
|
495
|
+
/** Logo frame `logo` with the wordmark beside it and `version` (styled) under the wordmark. */
|
|
496
|
+
function lockup(logo: readonly string[], version: string): string[] {
|
|
497
|
+
const beside = ["", ...WORDMARK.map(row => theme.bold(theme.fg("text", row))), version];
|
|
498
|
+
return logo.map((row, index) => `${row}${padding(LOCKUP_GAP)}${beside[index] ?? ""}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* `lines` indented as one block whose widest line is centered in `width`
|
|
503
|
+
* columns; pass a single line to center it on its own.
|
|
504
|
+
*/
|
|
505
|
+
function centerBlock(lines: readonly string[], width: number): string[] {
|
|
506
|
+
const widest = lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
|
507
|
+
const indent = padding(Math.max(0, Math.floor((width - widest) / 2)));
|
|
508
|
+
return lines.map(line => indent + line);
|
|
509
|
+
}
|
|
729
510
|
|
|
730
511
|
/** The block-grid brand mark as accent lines; `shimmer` declares the terminal-clocked shine sweep. */
|
|
731
512
|
export function logoNode(lines: readonly string[], shimmer: boolean): NativeNode {
|
|
@@ -27,18 +27,11 @@ const RESET = "\x1b[0m";
|
|
|
27
27
|
const MIN_SCENE_WIDTH = 56;
|
|
28
28
|
const MIN_SCENE_HEIGHT = 22;
|
|
29
29
|
|
|
30
|
+
/** Skip affordance; built at render time so it follows the live symbol preset. */
|
|
30
31
|
function skipHint(): string {
|
|
31
|
-
return
|
|
32
|
+
return tuiText("setupSkipHint", "press {key} to skip").replace("{key}", formatKeyHint("enter"));
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
/** Density ramp for the rippling water, lightest → heaviest. */
|
|
35
|
-
const WATER_RAMP = [
|
|
36
|
-
{ min: 0.62, char: "█" },
|
|
37
|
-
{ min: 0.5, char: "▓" },
|
|
38
|
-
{ min: 0.36, char: "▒" },
|
|
39
|
-
{ min: 0.24, char: "░" },
|
|
40
|
-
];
|
|
41
|
-
|
|
42
35
|
function starAt(x: number, y: number, frame: number): string {
|
|
43
36
|
const hash = (x * 73856093) ^ (y * 19349663) ^ (frame * 83492791);
|
|
44
37
|
const bucket = Math.abs(hash) % 97;
|
|
@@ -85,35 +78,88 @@ function waterJitter(x: number, y: number): number {
|
|
|
85
78
|
}
|
|
86
79
|
|
|
87
80
|
/**
|
|
88
|
-
*
|
|
89
|
-
* interfere, then a radial edge falloff and a downward
|
|
90
|
-
* ripples beneath the mark and dissolve them toward the
|
|
91
|
-
*
|
|
81
|
+
* Time-invariant terms of the rippling water for one screen size: three
|
|
82
|
+
* travelling sine waves interfere, then a radial edge falloff and a downward
|
|
83
|
+
* fade concentrate the ripples beneath the mark and dissolve them toward the
|
|
84
|
+
* edges/bottom. Only the sine phases move per tick, so everything else is
|
|
85
|
+
* computed once per size.
|
|
92
86
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
waterTop:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
87
|
+
interface WaterField {
|
|
88
|
+
readonly width: number;
|
|
89
|
+
readonly height: number;
|
|
90
|
+
readonly waterTop: number;
|
|
91
|
+
/** Per cell (row-major from `waterTop`): `dist * 0.55`. */
|
|
92
|
+
readonly radial: Float64Array;
|
|
93
|
+
/** Per cell: `x * 0.22 + y * 0.45`. */
|
|
94
|
+
readonly diagonal: Float64Array;
|
|
95
|
+
/** Per cell: `|dx| * 0.8 + dy * 0.5`. */
|
|
96
|
+
readonly cross: Float64Array;
|
|
97
|
+
/** Per cell: `(waterJitter(x, y) - 0.5) * 0.06`. */
|
|
98
|
+
readonly jitter: Float64Array;
|
|
99
|
+
/** Per column: `edge ** 0.7`. */
|
|
100
|
+
readonly edge: Float64Array;
|
|
101
|
+
/** Per water row: downward fade. */
|
|
102
|
+
readonly fade: Float64Array;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let waterField: WaterField | undefined;
|
|
106
|
+
|
|
107
|
+
function getWaterField(width: number, height: number, cx: number, waterTop: number): WaterField {
|
|
108
|
+
const cached = waterField;
|
|
109
|
+
if (cached && cached.width === width && cached.height === height && cached.waterTop === waterTop) return cached;
|
|
110
|
+
const waterHeight = Math.max(1, height - waterTop);
|
|
111
|
+
const rows = Math.max(0, height - waterTop);
|
|
112
|
+
const radial = new Float64Array(rows * width);
|
|
113
|
+
const diagonal = new Float64Array(rows * width);
|
|
114
|
+
const cross = new Float64Array(rows * width);
|
|
115
|
+
const jitter = new Float64Array(rows * width);
|
|
116
|
+
const edge = new Float64Array(width);
|
|
117
|
+
const fade = new Float64Array(rows);
|
|
118
|
+
for (let x = 0; x < width; x++) edge[x] = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.5)) ** 0.7;
|
|
119
|
+
for (let row = 0; row < rows; row++) {
|
|
120
|
+
const y = waterTop + row;
|
|
121
|
+
const dy = y - waterTop;
|
|
122
|
+
fade[row] = Math.max(0, 1 - (dy / waterHeight) * 0.55);
|
|
123
|
+
for (let x = 0; x < width; x++) {
|
|
124
|
+
const i = row * width + x;
|
|
125
|
+
const dx = (x - cx) / 2;
|
|
126
|
+
radial[i] = Math.sqrt(dx * dx + dy * dy) * 0.55;
|
|
127
|
+
diagonal[i] = x * 0.22 + y * 0.45;
|
|
128
|
+
cross[i] = Math.abs(dx) * 0.8 + dy * 0.5;
|
|
129
|
+
jitter[i] = (waterJitter(x, y) - 0.5) * 0.06;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
waterField = { width, height, waterTop, radial, diagonal, cross, jitter, edge, fade };
|
|
133
|
+
return waterField;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Water glyph for an amplitude, lightest → heaviest density ramp; undefined below the floor. */
|
|
137
|
+
function waterChar(amp: number): string | undefined {
|
|
138
|
+
if (amp > 0.62) return "█";
|
|
139
|
+
if (amp > 0.5) return "▓";
|
|
140
|
+
if (amp > 0.36) return "▒";
|
|
141
|
+
if (amp > 0.24) return "░";
|
|
142
|
+
return undefined;
|
|
113
143
|
}
|
|
114
144
|
|
|
145
|
+
/** Reused cell grid; resized when the screen changes. */
|
|
146
|
+
let splashCells: string[][] = [];
|
|
147
|
+
|
|
148
|
+
function resetCells(width: number, height: number): string[][] {
|
|
149
|
+
if (splashCells.length !== height || splashCells[0]?.length !== width) {
|
|
150
|
+
// oxlint-disable-next-line unicorn/no-new-array -- length preallocation
|
|
151
|
+
splashCells = Array.from({ length: height }, () => new Array<string>(width).fill(" "));
|
|
152
|
+
} else {
|
|
153
|
+
for (const row of splashCells) row.fill(" ");
|
|
154
|
+
}
|
|
155
|
+
return splashCells;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Per-frame gradient escapes, one per screen diagonal (the gradient only varies along it). */
|
|
159
|
+
let diagonalEscapes: (string | undefined)[] = [];
|
|
160
|
+
|
|
115
161
|
/**
|
|
116
|
-
* Animated setup splash, in the spirit of the
|
|
162
|
+
* Animated setup splash, in the spirit of the Zeta visual identity: the brand ζ
|
|
117
163
|
* mark rendered with the live diagonal gradient + shine sweep, rising out of a
|
|
118
164
|
* rippling, gradient-lit water surface, under a faint twinkling starfield. The
|
|
119
165
|
* mark and water share one continuous gradient so the sweep reads across the
|
|
@@ -132,23 +178,46 @@ export function renderSetupSplash(width: number, height: number, elapsedMs: numb
|
|
|
132
178
|
const cx = Math.floor(w / 2);
|
|
133
179
|
const surfaceTime = frame * 0.13;
|
|
134
180
|
|
|
135
|
-
|
|
136
|
-
const cells: string[][] = Array.from({ length: h }, () => new Array<string>(w).fill(" "));
|
|
181
|
+
const cells = resetCells(w, h);
|
|
137
182
|
const put = (x: number, y: number, glyph: string): void => {
|
|
138
183
|
if (y >= 0 && y < h && x >= 0 && x < w) cells[y][x] = glyph;
|
|
139
184
|
};
|
|
185
|
+
// Every cell on a diagonal shares one gradient position, so resolve each
|
|
186
|
+
// escape once per frame instead of once per cell.
|
|
187
|
+
const diagonals = w + h - 1;
|
|
188
|
+
if (diagonalEscapes.length !== diagonals) diagonalEscapes = Array.from({ length: diagonals });
|
|
189
|
+
else diagonalEscapes.fill(undefined);
|
|
190
|
+
const escapes = diagonalEscapes;
|
|
191
|
+
const gradient = (x: number, y: number): string => {
|
|
192
|
+
const d = x + (h - 1 - y);
|
|
193
|
+
let escape = escapes[d];
|
|
194
|
+
if (escape === undefined) {
|
|
195
|
+
escape = gradientEscape(screenGradientT(x, y, w, h, phase), shine);
|
|
196
|
+
escapes[d] = escape;
|
|
197
|
+
}
|
|
198
|
+
return escape;
|
|
199
|
+
};
|
|
140
200
|
|
|
141
201
|
const hx = Math.floor((w - LOGO_WIDTH) / 2);
|
|
142
202
|
const hy = Math.max(2, Math.floor(h * 0.16));
|
|
143
203
|
const waterTop = hy + LOGO_HEIGHT;
|
|
144
|
-
const waterHeight = Math.max(1, h - waterTop);
|
|
145
204
|
|
|
146
205
|
// 1. rippling water surface (shares the screen-wide gradient with the mark)
|
|
206
|
+
const field = getWaterField(w, h, cx, waterTop);
|
|
207
|
+
const tDiagonal = surfaceTime * 0.7;
|
|
208
|
+
const tCross = surfaceTime * 1.4;
|
|
147
209
|
for (let y = waterTop; y < h; y++) {
|
|
210
|
+
const row = y - waterTop;
|
|
211
|
+
const fade = field.fade[row];
|
|
148
212
|
for (let x = 0; x < w; x++) {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
213
|
+
const i = row * w + x;
|
|
214
|
+
const wave =
|
|
215
|
+
0.5 * Math.sin(field.radial[i] - surfaceTime) +
|
|
216
|
+
0.3 * Math.sin(field.diagonal[i] - tDiagonal) +
|
|
217
|
+
0.2 * Math.sin(field.cross[i] - tCross);
|
|
218
|
+
const level = 0.5 + 0.5 * wave;
|
|
219
|
+
const char = waterChar(level * field.edge[x] * fade + field.jitter[i]);
|
|
220
|
+
if (char) cells[y][x] = gradient(x, y) + char + RESET;
|
|
152
221
|
}
|
|
153
222
|
}
|
|
154
223
|
// 2. twinkling starfield in the sky above the water
|
|
@@ -162,18 +231,15 @@ export function renderSetupSplash(width: number, height: number, elapsedMs: numb
|
|
|
162
231
|
LARGE_LOGO.forEach((line, row) => {
|
|
163
232
|
let col = 0;
|
|
164
233
|
for (const ch of line) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
hy + row,
|
|
169
|
-
gradientEscape(screenGradientT(hx + col, hy + row, w, h, phase), shine) + ch + RESET,
|
|
170
|
-
);
|
|
171
|
-
}
|
|
234
|
+
const x = hx + col;
|
|
235
|
+
const y = hy + row;
|
|
236
|
+
if (ch !== " " && y >= 0 && y < h && x >= 0 && x < w) cells[y][x] = gradient(x, y) + ch + RESET;
|
|
172
237
|
col++;
|
|
173
238
|
}
|
|
174
239
|
});
|
|
175
|
-
// 4. skip hint on a cleared strip at the bottom so it stays legible over the water
|
|
176
|
-
|
|
240
|
+
// 4. skip hint on a cleared strip at the bottom so it stays legible over the water.
|
|
241
|
+
// `formatKeyHint` yields plain glyphs (no ANSI), so per-character dim styling is safe.
|
|
242
|
+
const hint = skipHint();
|
|
177
243
|
const hintWidth = visibleWidth(hint);
|
|
178
244
|
const hintStart = Math.floor((w - hintWidth) / 2);
|
|
179
245
|
const hintRow = h - 1;
|
|
@@ -219,9 +285,6 @@ function renderCompactSplash(width: number, height: number, phase: number, shine
|
|
|
219
285
|
lines.push(width > 0 ? padToWidth(item !== undefined ? centerLine(item, width) : "", width) : "");
|
|
220
286
|
}
|
|
221
287
|
if (height > 2)
|
|
222
|
-
lines[height - 2] =
|
|
223
|
-
width > 0
|
|
224
|
-
? padToWidth(centerLine(theme.fg("dim", tuiText("setupSkipHint", "press enter to skip")), width), width)
|
|
225
|
-
: "";
|
|
288
|
+
lines[height - 2] = width > 0 ? padToWidth(centerLine(theme.fg("dim", skipHint()), width), width) : "";
|
|
226
289
|
return lines;
|
|
227
290
|
}
|