@oh-my-pi/pi-tui 18.8.4 → 18.8.6

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.
@@ -5,21 +5,32 @@ import { editorKey } from "../chrome/keybinding-hints";
5
5
  import { getKeybindings, type Keybinding } from "../keybindings";
6
6
  import { card, col, keyed, node, row, span, text } from "../native/describe";
7
7
  import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
8
- import { plainLine } from "../native/spans";
8
+ import { compactText, plainText } from "../native/spans";
9
9
  import { isNativeRendering } from "../native/state";
10
+ import { isHyperlinkEnabled, urlHyperlink, urlLinkSpan } from "../render/hyperlink";
10
11
  import { TERMINAL } from "../terminal-capabilities";
11
12
  import { theme } from "../theme/theme";
12
13
  import type { Component } from "../tui";
13
14
  import { padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
14
15
  import tipsText from "./tips.txt" with { type: "text" };
15
16
 
16
- /** Tips embedded at build time, one per line; blanks dropped. Key placeholders
17
+ /** Leading marker for a tip that pitches Tern: shown only while no TSP surface
18
+ * is live, since a live one means the user is already in Tern. */
19
+ const NO_TERN_TIP_MARKER = /^\[NO-TERN\]\s*/;
20
+
21
+ /** tips.txt lines embedded at build time; blanks dropped. Key placeholders
17
22
  * (see {@link expandTipKeys}) stay raw until render time. */
18
- const TIPS: readonly string[] = tipsText
23
+ const TIP_LINES: readonly string[] = tipsText
19
24
  .split("\n")
20
25
  .map(line => line.trim())
21
26
  .filter(line => line.length > 0);
22
27
 
28
+ /** Tips for the classic renderer: every tip, {@link NO_TERN_TIP_MARKER} stripped. */
29
+ const TIPS: readonly string[] = TIP_LINES.map(line => line.replace(NO_TERN_TIP_MARKER, ""));
30
+
31
+ /** Tips for a live TSP surface: those marked {@link NO_TERN_TIP_MARKER} dropped. */
32
+ const TSP_TIPS: readonly string[] = TIP_LINES.filter(line => !NO_TERN_TIP_MARKER.test(line));
33
+
23
34
  /** Trailing marker that flags a tip as a "what's new" callout. Stripped before
24
35
  * wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT}
25
36
  * painted as a shimmering rainbow. Non-global so `.test` stays stateless. */
@@ -107,6 +118,31 @@ function expandTipKeys(tip: string): string {
107
118
  });
108
119
  }
109
120
 
121
+ /** Markdown-style links in tips.txt: `[Tern](https://scl.so/tern)` shows the label, linked to the URL. */
122
+ const TIP_LINK = /\[([^\]]+)\]\(([^\s)]+)\)/g;
123
+
124
+ /** Tip links as underlined OSC 8 labels, or `label (url)` where the terminal cannot link. */
125
+ function linkTipText(tip: string): string {
126
+ const linkable = isHyperlinkEnabled();
127
+ return tip.replace(TIP_LINK, (_link, label: string, url: string) =>
128
+ linkable ? urlHyperlink(url, theme.underline(label)) : `${label} (${url})`,
129
+ );
130
+ }
131
+
132
+ /** The tip as native text: escapes dropped, links as `href` spans. */
133
+ function tipSpans(tip: string): TspSpan[] {
134
+ const spans: TspSpan[] = [];
135
+ let cursor = 0;
136
+ for (const match of tip.matchAll(TIP_LINK)) {
137
+ const [link, label, url] = match;
138
+ if (match.index > cursor) spans.push(span(plainText(tip.slice(cursor, match.index))));
139
+ spans.push(urlLinkSpan(url, label));
140
+ cursor = match.index + link.length;
141
+ }
142
+ if (cursor < tip.length) spans.push(span(plainText(tip.slice(cursor))));
143
+ return spans;
144
+ }
145
+
110
146
  /**
111
147
  * The welcome tip as lines of at most `width` columns: `Tip:` and the body
112
148
  * wrapped together, with no indent, so the banner can center each line.
@@ -117,7 +153,7 @@ export function renderWelcomeTip(tip: string, width: number, phase = 0): string[
117
153
  if (width - visibleWidth(label) < 8) return [];
118
154
 
119
155
  const isNew = NEW_TIP_MARKER.test(tip);
120
- const body = expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip);
156
+ const body = linkTipText(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip));
121
157
 
122
158
  // Trailing spaces left by the wrap would count toward the width the banner centers.
123
159
  const wrapped = wrapTextWithAnsi(replaceTabs(`${label}${body}`), width).map(line => line.trimEnd());
@@ -164,9 +200,10 @@ export class WelcomeComponent implements Component {
164
200
  #animTimer: Timer | null = null;
165
201
  #requestRender: (() => void) | null = null;
166
202
  // Tip randomness is latched once so the tip is stable across renders, but
167
- // the nerdfont-nag gate re-reads the live preset: the startup prepaint can
168
- // run under the default "unicode" preset before settings resolve the real
169
- // one, and a memoized nag would survive the switch to "nerd".
203
+ // the nerdfont-nag gate re-reads the live preset and the pool re-reads the
204
+ // live TSP state: the startup prepaint can run under the default "unicode"
205
+ // preset before settings resolve the real one, and an optimistic Tern start
206
+ // falls back to the classic renderer when `hello` never confirms.
170
207
  #nagRoll: number | undefined;
171
208
  #tipRoll: number | undefined;
172
209
  // Render cache: the welcome box is the first transcript-area component, so
@@ -183,7 +220,7 @@ export class WelcomeComponent implements Component {
183
220
  if (theme.getSymbolPreset() === "unicode" && this.#nagRoll < 0.1) {
184
221
  return "Please use nerdfont 😭.";
185
222
  }
186
- return pickWeightedTip(TIPS, this.#tipRoll) || undefined;
223
+ return pickWeightedTip(isNativeRendering() ? TSP_TIPS : TIPS, this.#tipRoll) || undefined;
187
224
  }
188
225
 
189
226
  invalidate(): void {
@@ -237,7 +274,7 @@ export class WelcomeComponent implements Component {
237
274
  const body: NativeChild[] = [lockupRow];
238
275
  if (tip) {
239
276
  const isNew = NEW_TIP_MARKER.test(tip);
240
- const tipText = plainLine(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip));
277
+ const tipText = compactText(tipSpans(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip)));
241
278
  const tipRow: NativeChild[] = [
242
279
  node("icon", { name: "lightbulb", role: "omp.welcome.tip-icon" }),
243
280
  text(tipText, { wrap: "word", role: "omp.welcome.tip-text" }),
@@ -34,6 +34,7 @@ export {
34
34
  createHighlightStream,
35
35
  getEditorTheme,
36
36
  getMarkdownTheme,
37
+ getMarkdownThemeWithLinkTargets,
37
38
  getSelectListTheme,
38
39
  getSettingsListTheme,
39
40
  getSymbolTheme,
@@ -183,8 +183,75 @@ export function getSymbolTheme(): SymbolTheme {
183
183
 
184
184
  let cachedMarkdownTheme: MarkdownTheme | undefined;
185
185
  let cachedMarkdownThemeRef: Theme | undefined;
186
+ const cacheableMarkdownThemes = new WeakMap<
187
+ MarkdownTheme,
188
+ {
189
+ theme: Theme;
190
+ fields: ReadonlyArray<readonly [PropertyKey, unknown]>;
191
+ symbolFields: ReadonlyArray<readonly [PropertyKey, unknown]>;
192
+ symbolValues: string;
193
+ }
194
+ >();
195
+ const linkTargetSnapshots = new WeakMap<ReadonlyMap<string, string>, ReadonlyMap<string, string>>();
186
196
  let markdownMermaidRendering = true;
187
197
 
198
+ function ownFields(object: object): ReadonlyArray<readonly [PropertyKey, unknown]> {
199
+ return Reflect.ownKeys(object).map(key => [key, Object.getOwnPropertyDescriptor(object, key)?.value]);
200
+ }
201
+
202
+ function hasUnchangedOwnFields(object: object, fields: ReadonlyArray<readonly [PropertyKey, unknown]>): boolean {
203
+ if (Reflect.ownKeys(object).length !== fields.length) return false;
204
+ return fields.every(([key, value]) => {
205
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
206
+ return descriptor !== undefined && "value" in descriptor && descriptor.value === value;
207
+ });
208
+ }
209
+
210
+ /** Only themes assembled here have stable callbacks and private link state. */
211
+ export function canCacheMarkdownListItems(candidate: MarkdownTheme): boolean {
212
+ const registered = cacheableMarkdownThemes.get(candidate);
213
+ if (registered === undefined || registered.theme !== theme || candidate.symbols === undefined) return false;
214
+ if (!hasUnchangedOwnFields(candidate, registered.fields)) return false;
215
+ if (!hasUnchangedOwnFields(candidate.symbols, registered.symbolFields)) return false;
216
+ try {
217
+ return JSON.stringify(candidate.symbols) === registered.symbolValues;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ function registerCacheableMarkdownTheme(markdownTheme: MarkdownTheme): void {
224
+ if (markdownTheme.symbols === undefined) return;
225
+ cacheableMarkdownThemes.set(markdownTheme, {
226
+ theme,
227
+ fields: ownFields(markdownTheme),
228
+ symbolFields: ownFields(markdownTheme.symbols),
229
+ symbolValues: JSON.stringify(markdownTheme.symbols),
230
+ });
231
+ }
232
+
233
+ export function getMarkdownThemeWithLinkTargets(targets: ReadonlyMap<string, string>): MarkdownTheme {
234
+ const base = getMarkdownTheme();
235
+ let snapshot = linkTargetSnapshots.get(targets);
236
+ let matches = snapshot?.size === targets.size;
237
+ if (matches && snapshot !== undefined) {
238
+ for (const [href, target] of targets) {
239
+ if (snapshot.get(href) !== target) {
240
+ matches = false;
241
+ break;
242
+ }
243
+ }
244
+ }
245
+ if (snapshot === undefined || !matches) {
246
+ snapshot = new Map(targets);
247
+ linkTargetSnapshots.set(targets, snapshot);
248
+ }
249
+ const stableSnapshot = snapshot;
250
+ const linked: MarkdownTheme = { ...base, resolveLink: href => stableSnapshot.get(href) };
251
+ if (canCacheMarkdownListItems(base)) registerCacheableMarkdownTheme(linked);
252
+ return linked;
253
+ }
254
+
188
255
  export function setMarkdownMermaidRendering(enabled: boolean): void {
189
256
  if (markdownMermaidRendering === enabled) return;
190
257
  markdownMermaidRendering = enabled;
@@ -247,6 +314,7 @@ export function getMarkdownTheme(): MarkdownTheme {
247
314
  };
248
315
  cachedMarkdownTheme = markdownTheme;
249
316
  cachedMarkdownThemeRef = theme;
317
+ registerCacheableMarkdownTheme(markdownTheme);
250
318
  return markdownTheme;
251
319
  }
252
320