@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 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, type RecentSession, type LspServerInfo } from "./welcome.js";
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, NativeUiEvent } from "../native/node.js";
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: `Tip:` and the body
19
- * wrapped together, with no indent, so the banner can center each line.
20
- * `[]` when `width` leaves no room for a useful line.
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
- * Premium welcome screen with block-based ZETA logo and two-column layout.
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
- private modelName;
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`) titled with the app version. The brand column
52
- * (`zeta.welcome.brand`: greeting and the terminal's builtin `zeta` mark, which
53
- * it animates) sits beside the info column (`zeta.welcome.info`: prompt-sigil
54
- * keycaps, LSP servers, recent sessions); the tip of the session closes the card. Roles
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 omp landing page: the brand ζ
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.0",
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.0",
42
- "@linxiraos/pi-agent-core": "1.2.0",
43
- "@linxiraos/pi-ai": "1.2.0",
44
- "@linxiraos/pi-catalog": "1.2.0",
45
- "@linxiraos/pi-natives": "1.2.0",
46
- "@linxiraos/pi-utils": "1.2.0",
47
- "@linxiraos/pi-wire": "1.2.0",
48
- "@linxiraos/pi-snapcompact": "1.2.0"
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 omp-dev --continue, or spawn a task here.", "dim")], {
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 omp-dev --continue, or spawn a task here."),
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 marketplace plugins: zeta-c plugin install <name>@<marketplace>"), 0, 0),
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, welcome model labels,
7
- * status-bar inputs) are also written under the empty project, so a folder that
8
- * never ran zeta-c still paints with the user's theme and status bar: those are
9
- * rarely project-specific, and path/branch render live. Recent sessions and LSP
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";
@@ -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, type RecentSession, type LspServerInfo } from "./welcome";
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 {
@@ -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, kbd, keyed, node, row, span, text } from "../native/describe";
7
- import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
8
- import { runTranscriptAction } from "../chat/transcript-actions";
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, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
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 {@link NEW_TAG_TEXT} on a moving HSL rainbow. `phase`
81
- * rotates the hue offset cyclically; successive renders with increasing phase
82
- * shimmer, while a fixed phase yields a still rainbow. */
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 = [...NEW_TAG_TEXT];
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: `Tip:` and the body
162
- * wrapped together, with no indent, so the banner can center each line.
163
- * `[]` when `width` leaves no room for a useful line.
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
- const labelWidth = visibleWidth(label);
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
- const wrappedBody = wrapTextWithAnsi(replaceTabs(body), bodyBudget);
175
- if (wrappedBody.length === 0) return [];
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
- const lines = wrappedBody.map((line, index) => {
184
- const styledBody = theme.fg("muted", line);
185
- const content = index === 0 ? `${styledLabel}${styledBody}` : `${styledBody}`;
186
- return theme.italic(content);
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 body line when it fits within the
191
- // box; otherwise drop it onto its own indented continuation line so the
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 tag = renderNewTag(phase, encoding);
195
- const tagWidth = 1 + visibleWidth(NEW_TAG_TEXT); // 1 = space separator
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(continuationIndent + tag);
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
- * Premium welcome screen with block-based ZETA logo and two-column layout.
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
- #native: { tip: string | undefined; node: NativeNode } | undefined;
240
-
241
- constructor(
242
- private version: string,
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 "Please use nerdfont 😭.";
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`) titled with the app version. The brand column
266
- * (`zeta.welcome.brand`: greeting and the terminal's builtin `zeta` mark, which
267
- * it animates) sits beside the info column (`zeta.welcome.info`: prompt-sigil
268
- * keycaps, LSP servers, recent sessions); the tip of the session closes the card. Roles
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 tip = this.tip;
274
- if (this.#native && this.#native.tip === tip) return this.#native.node;
275
- const line = (spans: readonly TspSpan[], role?: string, key?: string): NativeNode =>
276
- keyed(text(spans, { wrap: "none", truncate: "end", role }), key ?? role ?? "");
277
- // Centered brand lines are short and fixed; the logo is multi-line art that must never truncate.
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 brand = keyed(
281
- col(
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
- row(
314
+ col(
334
315
  [
335
- text([span("●", token)], { role: "zeta.welcome.lsp-dot", title: server.status, aria: symbol }),
336
- line([span(server.name)]),
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
- { gap: "sm", role: "zeta.welcome.lsp-row" },
319
+ { role: "zeta.welcome.mark" },
342
320
  ),
343
- server.name,
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 isNew = NEW_TIP_MARKER.test(tip);
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) tipRow.push(node("shimmer", { text: "New", role: "zeta.welcome.new" }));
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 in the info column.
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
- if (!animating && this.#cachedLines && this.#cachedWidth === termWidth) {
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
- // Box dimensions - responsive with max width and small-terminal support
505
- const maxWidth = 100;
506
- const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
507
- if (boxWidth < 4) {
508
- return [];
509
- }
510
- const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
511
- const preferredLeftCol = 26;
512
- const minLeftCol = 12; // logo width
513
- const minRightCol = 20;
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
- * Render the per-instance tip line: the `customMessageLabel`-themed `Tip:`
668
- * label followed by a `muted` body, the whole line italicized. Returns `[]`
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(boxWidth: number): string[] {
672
- const tip = this.tip;
673
- if (!tip) return [];
674
- // A trailing "[NEW]" marker paints an animated rainbow "NEW!" tag. Derive
675
- // its hue phase from wall-clock time so it shimmers across the welcome
676
- // intro's re-render frames, then settles into a still rainbow once the box
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 phase = NEW_TIP_MARKER.test(tip) ? performance.now() / NEW_GLOW_PERIOD_MS : 0;
679
- return renderWelcomeTip(tip, boxWidth, phase);
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 `press ${formatKeyHint("enter")} to skip`;
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
- * Rippling water amplitude in [0,1] at (x, y): three travelling sine waves
89
- * interfere, then a radial edge falloff and a downward fade concentrate the
90
- * ripples beneath the mark and dissolve them toward the edges/bottom. `t`
91
- * advances each tick, so the surface drifts.
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
- function waterAmplitude(
94
- x: number,
95
- y: number,
96
- cx: number,
97
- waterTop: number,
98
- waterHeight: number,
99
- width: number,
100
- t: number,
101
- ): number {
102
- const dx = (x - cx) / 2;
103
- const dy = y - waterTop;
104
- const dist = Math.sqrt(dx * dx + dy * dy);
105
- const wave =
106
- 0.5 * Math.sin(dist * 0.55 - t) +
107
- 0.3 * Math.sin(x * 0.22 + y * 0.45 - t * 0.7) +
108
- 0.2 * Math.sin(Math.abs(dx) * 0.8 + dy * 0.5 - t * 1.4);
109
- const level = 0.5 + 0.5 * wave;
110
- const edge = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.5));
111
- const fade = Math.max(0, 1 - (dy / Math.max(1, waterHeight)) * 0.55);
112
- return level * edge ** 0.7 * fade;
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 omp landing page: the brand ζ
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
- // [suppressed] length preallocation
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 amp = waterAmplitude(x, y, cx, waterTop, waterHeight, w, surfaceTime) + (waterJitter(x, y) - 0.5) * 0.06;
150
- const cell = WATER_RAMP.find(step => amp > step.min);
151
- if (cell) put(x, y, gradientEscape(screenGradientT(x, y, w, h, phase), shine) + cell.char + RESET);
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
- if (ch !== " ") {
166
- put(
167
- hx + col,
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
- const hint = tuiText("setupSkipHint", "press enter to skip");
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
  }