@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.
- package/CHANGELOG.md +23 -0
- package/dist/types/chat/user-message.d.ts +1 -1
- package/dist/types/overlays/model-browser.d.ts +13 -0
- package/dist/types/overlays/model-hub.d.ts +6 -0
- package/dist/types/overlays/session-selector.d.ts +5 -1
- package/dist/types/overlays/usage-dashboard.d.ts +5 -3
- package/dist/types/prompt/custom-editor.d.ts +2 -2
- package/dist/types/prompt/image-source.d.ts +2 -1
- package/dist/types/theme/theme.d.ts +1 -1
- package/dist/types/theme/tui-adapters.d.ts +3 -0
- package/package.json +13 -9
- package/src/chat/assistant-message.ts +2 -2
- package/src/chat/user-message.ts +1 -1
- package/src/components/editor.ts +6 -3
- package/src/components/markdown.ts +182 -8
- package/src/overlays/model-browser.ts +26 -1
- package/src/overlays/model-hub.ts +172 -21
- package/src/overlays/session-selector.ts +11 -3
- package/src/overlays/usage-dashboard.ts +18 -7
- package/src/prompt/custom-editor.ts +3 -4
- package/src/prompt/image-source.ts +2 -1
- package/src/prompt/tips.txt +2 -1
- package/src/prompt/welcome.ts +46 -9
- package/src/theme/theme.ts +1 -0
- package/src/theme/tui-adapters.ts +68 -0
package/src/prompt/welcome.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
168
|
-
// run under the default "unicode"
|
|
169
|
-
//
|
|
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 =
|
|
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" }),
|
package/src/theme/theme.ts
CHANGED
|
@@ -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
|
|