@oh-my-pi/pi-tui 18.8.5 → 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 +12 -0
- package/dist/types/overlays/session-selector.d.ts +5 -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/components/markdown.ts +182 -8
- package/src/overlays/model-hub.ts +39 -4
- package/src/overlays/session-selector.ts +11 -3
- 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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.8.6] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Improved rendering performance for streaming long Markdown lists while preserving nested items, numbering, and reference links.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Added package exports for the native Tern/TSP modules, allowing extensions in compiled `omp` binaries to import them.
|
|
14
|
+
- Fixed the Tern `/model` picker's Roles tab so typing and Backspace consistently edit the search field without triggering role commands.
|
|
15
|
+
- Fixed the session picker so sessions retain and display their directory when switching between the current folder and all-project views.
|
|
16
|
+
|
|
5
17
|
## [18.8.5] - 2026-10-08
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -129,7 +129,11 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
|
|
|
129
129
|
title?: string;
|
|
130
130
|
/** Fixed scope label, or false to omit the scope suffix. */
|
|
131
131
|
scopeLabel?: string | false;
|
|
132
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Show each session's working directory in the list. Defaults to on when
|
|
134
|
+
* the session files live in more than one directory (e.g. the folder scope
|
|
135
|
+
* merging a repository's worktrees).
|
|
136
|
+
*/
|
|
133
137
|
showCwd?: boolean;
|
|
134
138
|
/**
|
|
135
139
|
* Reads the live terminal height so the visible window fits the viewport.
|
|
@@ -6,7 +6,7 @@ export { getAvailableThemes, getAvailableThemesWithPaths, getThemeByName, type T
|
|
|
6
6
|
export { isValidThemeColor, type ThemeBg, type ThemeColor } from "./schema.js";
|
|
7
7
|
export { getAvailableSymbolPresets, isValidSymbolPreset, type SpinnerType, type SymbolKey, type SymbolPreset, } from "./symbols.js";
|
|
8
8
|
export { Theme } from "./theme-class.js";
|
|
9
|
-
export { createHighlightStream, getEditorTheme, getMarkdownTheme, getSelectListTheme, getSettingsListTheme, getSymbolTheme, highlightCode, setMarkdownMermaidRendering, warmHighlighter, } from "./tui-adapters.js";
|
|
9
|
+
export { createHighlightStream, getEditorTheme, getMarkdownTheme, getMarkdownThemeWithLinkTargets, getSelectListTheme, getSettingsListTheme, getSymbolTheme, highlightCode, setMarkdownMermaidRendering, warmHighlighter, } from "./tui-adapters.js";
|
|
10
10
|
/** Active theme; imports remain live across initialization, previews, and theme switches. */
|
|
11
11
|
export declare var theme: Theme;
|
|
12
12
|
type ThemeBinding = (value: Theme) => void;
|
|
@@ -16,6 +16,9 @@ export declare function createHighlightStream(lang?: string, highlightTheme?: Th
|
|
|
16
16
|
/** Warm native syntax grammars off-thread once per process. */
|
|
17
17
|
export declare function warmHighlighter(): Promise<void>;
|
|
18
18
|
export declare function getSymbolTheme(): SymbolTheme;
|
|
19
|
+
/** Only themes assembled here have stable callbacks and private link state. */
|
|
20
|
+
export declare function canCacheMarkdownListItems(candidate: MarkdownTheme): boolean;
|
|
21
|
+
export declare function getMarkdownThemeWithLinkTargets(targets: ReadonlyMap<string, string>): MarkdownTheme;
|
|
19
22
|
export declare function setMarkdownMermaidRendering(enabled: boolean): void;
|
|
20
23
|
export declare function getMarkdownTheme(): MarkdownTheme;
|
|
21
24
|
export declare function getSelectListTheme(): SelectListTheme;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.8.
|
|
4
|
+
"version": "18.8.6",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.8.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.8.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.8.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.8.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.8.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.8.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.8.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.8.
|
|
44
|
+
"@oh-my-pi/omptype": "18.8.6",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.8.6",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.8.6",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.8.6",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.8.6",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.8.6",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.8.6",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.8.6"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -105,6 +105,10 @@
|
|
|
105
105
|
"types": "./dist/types/components/*.d.ts",
|
|
106
106
|
"import": "./src/components/*.ts"
|
|
107
107
|
},
|
|
108
|
+
"./native/*": {
|
|
109
|
+
"types": "./dist/types/native/*.d.ts",
|
|
110
|
+
"import": "./src/native/*.ts"
|
|
111
|
+
},
|
|
108
112
|
"./*.js": "./src/*.ts"
|
|
109
113
|
}
|
|
110
114
|
}
|
|
@@ -9,7 +9,7 @@ import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
|
|
|
9
9
|
import chalk from "@oh-my-pi/pi-utils/chalk";
|
|
10
10
|
import { LRUCache } from "@oh-my-pi/pi-utils/lru";
|
|
11
11
|
import type { AssistantThinkingRenderer } from "./extension-types";
|
|
12
|
-
import { ensureThemeSync, getMarkdownTheme, getThemeEpoch, theme } from "../theme";
|
|
12
|
+
import { ensureThemeSync, getMarkdownTheme, getMarkdownThemeWithLinkTargets, getThemeEpoch, theme } from "../theme";
|
|
13
13
|
import { card, col, elapsed, node, span, text } from "../native/describe";
|
|
14
14
|
import { hasTranscriptActions, runTranscriptAction } from "./transcript-actions";
|
|
15
15
|
import type { NativeChild, NativeNode, NativeUiEvent } from "../native/node";
|
|
@@ -394,7 +394,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
394
394
|
if (this.#markdownTheme) return this.#markdownTheme;
|
|
395
395
|
const base = getMarkdownTheme();
|
|
396
396
|
const snapshot = this.#linkTargets;
|
|
397
|
-
const markdownTheme = snapshot.size > 0 ?
|
|
397
|
+
const markdownTheme = snapshot.size > 0 ? getMarkdownThemeWithLinkTargets(snapshot) : base;
|
|
398
398
|
this.#markdownTheme = markdownTheme;
|
|
399
399
|
return markdownTheme;
|
|
400
400
|
}
|
|
@@ -25,7 +25,8 @@ import { md } from "../native/describe";
|
|
|
25
25
|
import type { DescribeContext, NativeNode } from "../native/node";
|
|
26
26
|
import type { SymbolTheme } from "../symbols";
|
|
27
27
|
import { TERMINAL } from "../terminal-capabilities";
|
|
28
|
-
import { getSymbolTheme } from "../theme/theme";
|
|
28
|
+
import { getSymbolTheme, getThemeEpoch } from "../theme/theme";
|
|
29
|
+
import { canCacheMarkdownListItems } from "../theme/tui-adapters";
|
|
29
30
|
import type { Component } from "../tui";
|
|
30
31
|
import {
|
|
31
32
|
applyBackgroundToLine,
|
|
@@ -1390,10 +1391,27 @@ interface InlineStyleContext {
|
|
|
1390
1391
|
stylePrefix: string;
|
|
1391
1392
|
}
|
|
1392
1393
|
|
|
1393
|
-
type ListToken = Token & {
|
|
1394
|
+
type ListToken = Token & {
|
|
1395
|
+
items: Array<{ raw: string; tokens?: Token[] }>;
|
|
1396
|
+
ordered: boolean;
|
|
1397
|
+
start?: number;
|
|
1398
|
+
loose: boolean;
|
|
1399
|
+
};
|
|
1394
1400
|
type TableCellToken = { tokens?: Token[] };
|
|
1395
1401
|
type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
|
|
1396
1402
|
|
|
1403
|
+
/** `item` as marked lexes it inside a loose list: tight `text` blocks become paragraphs. */
|
|
1404
|
+
function loosenListItem(item: Tokens.ListItem): Tokens.ListItem {
|
|
1405
|
+
if (item.loose) return item;
|
|
1406
|
+
return {
|
|
1407
|
+
...item,
|
|
1408
|
+
loose: true,
|
|
1409
|
+
tokens: item.tokens.map(token =>
|
|
1410
|
+
token.type === "text" ? ({ ...token, type: "paragraph" } as Tokens.Paragraph) : token,
|
|
1411
|
+
),
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1397
1415
|
function formatHyperlink(text: string, target: string): string {
|
|
1398
1416
|
if (!TERMINAL.hyperlinks || !target) {
|
|
1399
1417
|
return text;
|
|
@@ -1743,6 +1761,25 @@ interface TailRowCache extends RenderSignature {
|
|
|
1743
1761
|
// type of token[i+1] when the rows were produced (blank/spacing gate).
|
|
1744
1762
|
nextTypes: (string | undefined)[];
|
|
1745
1763
|
}
|
|
1764
|
+
interface ListItemRowsCache {
|
|
1765
|
+
signature: RenderSignature;
|
|
1766
|
+
width: number;
|
|
1767
|
+
themeEpoch: number;
|
|
1768
|
+
ordered: boolean;
|
|
1769
|
+
start: number | undefined;
|
|
1770
|
+
loose: boolean;
|
|
1771
|
+
raws: string[];
|
|
1772
|
+
rows: RenderedLine[][];
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
interface StreamingListCache {
|
|
1776
|
+
text: string;
|
|
1777
|
+
before: Token[];
|
|
1778
|
+
start: number;
|
|
1779
|
+
tailStart: number;
|
|
1780
|
+
stableCount: number;
|
|
1781
|
+
list: Tokens.List;
|
|
1782
|
+
}
|
|
1746
1783
|
/**
|
|
1747
1784
|
* Mutable per-token record collector passed to #renderContentLines while
|
|
1748
1785
|
* rendering the streaming tail. The render loop fills `raws`/`nextTypes`
|
|
@@ -1856,6 +1893,8 @@ export class Markdown implements Component {
|
|
|
1856
1893
|
// the blank-replacement branch of setText and the fallback branch of
|
|
1857
1894
|
// #lexTokens.
|
|
1858
1895
|
#tailRowCache?: TailRowCache;
|
|
1896
|
+
#listItemRowsCache?: ListItemRowsCache;
|
|
1897
|
+
#streamingListCache?: StreamingListCache;
|
|
1859
1898
|
// True while #renderStreamingContentLines renders the frozen token range:
|
|
1860
1899
|
// frozen code blocks highlight even in transient mode so their bytes match
|
|
1861
1900
|
// the finalized render (they render once into the prefix line cache, so
|
|
@@ -1962,6 +2001,7 @@ export class Markdown implements Component {
|
|
|
1962
2001
|
// the non-append edit, so drop the frozen stream state here or it
|
|
1963
2002
|
// outlives the content it indexed.
|
|
1964
2003
|
this.#dropStreamPrefix();
|
|
2004
|
+
this.#listItemRowsCache = undefined;
|
|
1965
2005
|
// B+: the captured fast-path rows index the replaced content — drop
|
|
1966
2006
|
// the recipe so a fresh stream cannot splice onto stale rows.
|
|
1967
2007
|
this.#fastTail = undefined;
|
|
@@ -2031,7 +2071,9 @@ export class Markdown implements Component {
|
|
|
2031
2071
|
// render consumes it (see #lexTokens / the L2 hit path).
|
|
2032
2072
|
this.#streamPrefixLineCache = undefined;
|
|
2033
2073
|
this.#tailRowCache = undefined;
|
|
2074
|
+
this.#listItemRowsCache = undefined;
|
|
2034
2075
|
this.#streamingHighlightCache = undefined;
|
|
2076
|
+
this.#streamingListCache = undefined;
|
|
2035
2077
|
this.#streamRewound = undefined;
|
|
2036
2078
|
}
|
|
2037
2079
|
this.invalidate();
|
|
@@ -2108,32 +2150,98 @@ export class Markdown implements Component {
|
|
|
2108
2150
|
}
|
|
2109
2151
|
this.#lastScanLength = text.length;
|
|
2110
2152
|
this.#lastScanCanStream = canStream;
|
|
2153
|
+
if (!canStream) this.#listItemRowsCache = undefined;
|
|
2111
2154
|
this.#lastScanValid = true;
|
|
2112
2155
|
this.#appendOnlySinceLastScan = true;
|
|
2156
|
+
if (canStream && retainPrefix) {
|
|
2157
|
+
const tokens = this.#resumeStreamingList(text);
|
|
2158
|
+
if (tokens !== undefined) {
|
|
2159
|
+
this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
|
|
2160
|
+
this.#checkpointStreamingList(text, tokens);
|
|
2161
|
+
return tokens;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2113
2164
|
if (canStream && hasPrefix) {
|
|
2114
|
-
const tailTokens =
|
|
2115
|
-
//
|
|
2116
|
-
//
|
|
2117
|
-
// can resolve a reference in the frozen prefix, which was lexed
|
|
2165
|
+
const tailTokens = lexDocument(refDefText);
|
|
2166
|
+
// A definition at any nesting depth registers for the whole document
|
|
2167
|
+
// and can resolve a reference in the frozen prefix, which was lexed
|
|
2118
2168
|
// without it, so any definition in the tail sends the text to a full lex.
|
|
2119
2169
|
if (Object.keys(tailTokens.links).length === 0) {
|
|
2120
2170
|
const tokens = [...prefixTokens, ...tailTokens];
|
|
2121
2171
|
if (retainPrefix) this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
|
|
2122
2172
|
else this.#dropStreamPrefix();
|
|
2173
|
+
if (retainPrefix) this.#checkpointStreamingList(text, tokens);
|
|
2123
2174
|
return tokens;
|
|
2124
2175
|
}
|
|
2125
2176
|
}
|
|
2126
|
-
const tokens =
|
|
2177
|
+
const tokens = lexDocument(text);
|
|
2178
|
+
const hasReferenceDefinitions = Object.keys(tokens.links).length > 0;
|
|
2179
|
+
if (hasReferenceDefinitions) this.#lastScanCanStream = false;
|
|
2127
2180
|
// A definition frozen into the prefix would be missing from every later
|
|
2128
2181
|
// tail lex, so a full lex that registered any definition freezes nothing.
|
|
2129
|
-
if (canStream && retainPrefix &&
|
|
2182
|
+
if (canStream && retainPrefix && !hasReferenceDefinitions) {
|
|
2130
2183
|
this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
|
|
2184
|
+
this.#checkpointStreamingList(text, tokens);
|
|
2131
2185
|
} else {
|
|
2132
2186
|
this.#dropStreamPrefix();
|
|
2133
2187
|
}
|
|
2134
2188
|
return tokens;
|
|
2135
2189
|
}
|
|
2136
2190
|
|
|
2191
|
+
#resumeStreamingList(text: string): Token[] | undefined {
|
|
2192
|
+
const cached = this.#streamingListCache;
|
|
2193
|
+
if (cached === undefined || text.length <= cached.text.length || !text.startsWith(cached.text)) return undefined;
|
|
2194
|
+
const tail = lexDocument(text.slice(cached.tailStart));
|
|
2195
|
+
if (Object.keys(tail.links).length > 0) return undefined;
|
|
2196
|
+
const first = tail[0];
|
|
2197
|
+
if (first?.type !== "list") return undefined;
|
|
2198
|
+
const list = first as Tokens.List;
|
|
2199
|
+
if (!listMayContinueAt(list.raw, 0, cached.list.raw)) return undefined;
|
|
2200
|
+
const loose = cached.list.loose || list.loose;
|
|
2201
|
+
const completed = cached.list.items.slice(0, cached.stableCount);
|
|
2202
|
+
const items = [...completed, ...list.items];
|
|
2203
|
+
const merged: Tokens.List = {
|
|
2204
|
+
...cached.list,
|
|
2205
|
+
raw: text.slice(cached.start, cached.tailStart + list.raw.length),
|
|
2206
|
+
loose,
|
|
2207
|
+
items: loose ? items.map(loosenListItem) : items,
|
|
2208
|
+
};
|
|
2209
|
+
return [...cached.before, merged, ...tail.slice(1)];
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
#checkpointStreamingList(text: string, tokens: Token[]): void {
|
|
2213
|
+
const previous = this.#streamingListCache;
|
|
2214
|
+
this.#streamingListCache = undefined;
|
|
2215
|
+
let index = tokens.length - 1;
|
|
2216
|
+
while (tokens[index]?.type === "space") index--;
|
|
2217
|
+
const token = tokens[index];
|
|
2218
|
+
if (token?.type !== "list") return;
|
|
2219
|
+
const list = token as Tokens.List;
|
|
2220
|
+
let start = 0;
|
|
2221
|
+
for (let i = 0; i < index; i++) start += tokens[i]!.raw.length;
|
|
2222
|
+
// Late block delimiters can absorb an unfrozen preceding region. Bare
|
|
2223
|
+
// environments can also absorb list items through an equation prefix.
|
|
2224
|
+
if (start !== (this.#streamPrefixText?.length ?? 0) || BARE_ENV_BEGIN.test(list.raw)) return;
|
|
2225
|
+
const canResume = previous !== undefined && previous.start === start && text.startsWith(previous.text);
|
|
2226
|
+
let cursor = canResume ? previous.tailStart : start;
|
|
2227
|
+
let tailStart = start;
|
|
2228
|
+
let stableCount = 0;
|
|
2229
|
+
const indent = /^ */.exec(list.raw)![0].length;
|
|
2230
|
+
for (let i = canResume ? previous.stableCount : 0; i < list.items.length; i++) {
|
|
2231
|
+
const item = list.items[i]!;
|
|
2232
|
+
const position = text.indexOf(item.raw, cursor);
|
|
2233
|
+
if (position < cursor || position >= start + list.raw.length) return;
|
|
2234
|
+
// List continuation uses the first item's indentation. Resume only at
|
|
2235
|
+
// an item with that same indentation so the lexer sees the same context.
|
|
2236
|
+
if (/^ */.exec(item.raw)![0].length === indent) {
|
|
2237
|
+
tailStart = position;
|
|
2238
|
+
stableCount = i;
|
|
2239
|
+
}
|
|
2240
|
+
cursor = position + item.raw.length;
|
|
2241
|
+
}
|
|
2242
|
+
this.#streamingListCache = { text, before: tokens.slice(0, index), start, tailStart, stableCount, list };
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2137
2245
|
/**
|
|
2138
2246
|
* Drop the frozen lex prefix and the transient row caches keyed on it. A
|
|
2139
2247
|
* prefix set aside for #restoreRewoundPrefix checks itself against each
|
|
@@ -2146,6 +2254,8 @@ export class Markdown implements Component {
|
|
|
2146
2254
|
this.#streamSettledText = undefined;
|
|
2147
2255
|
this.#streamPrefixLineCache = undefined;
|
|
2148
2256
|
this.#tailRowCache = undefined;
|
|
2257
|
+
this.#listItemRowsCache = undefined;
|
|
2258
|
+
this.#streamingListCache = undefined;
|
|
2149
2259
|
}
|
|
2150
2260
|
|
|
2151
2261
|
/**
|
|
@@ -2186,6 +2296,8 @@ export class Markdown implements Component {
|
|
|
2186
2296
|
#rewindStreamPrefix(end: number, count: number): void {
|
|
2187
2297
|
this.#streamPrefixLineCache = rewoundLineCache(this.#streamPrefixLineCache, count);
|
|
2188
2298
|
this.#tailRowCache = undefined;
|
|
2299
|
+
this.#listItemRowsCache = undefined;
|
|
2300
|
+
this.#streamingListCache = undefined;
|
|
2189
2301
|
if (count === 0) {
|
|
2190
2302
|
this.#streamPrefixText = undefined;
|
|
2191
2303
|
this.#streamPrefixTokens = undefined;
|
|
@@ -2223,6 +2335,8 @@ export class Markdown implements Component {
|
|
|
2223
2335
|
this.#streamPrefixOpeners = rewound.openers;
|
|
2224
2336
|
this.#streamPrefixLineCache = rewound.lineCache;
|
|
2225
2337
|
this.#tailRowCache = rewound.tailRowCache;
|
|
2338
|
+
this.#listItemRowsCache = undefined;
|
|
2339
|
+
this.#streamingListCache = undefined;
|
|
2226
2340
|
}
|
|
2227
2341
|
|
|
2228
2342
|
// Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
|
|
@@ -2591,6 +2705,7 @@ export class Markdown implements Component {
|
|
|
2591
2705
|
signature: RenderSignature,
|
|
2592
2706
|
contentWidth: number,
|
|
2593
2707
|
): string[] {
|
|
2708
|
+
if (tokens[tokens.length - 1]?.type !== "list") this.#listItemRowsCache = undefined;
|
|
2594
2709
|
const stableText = this.#streamPrefixText;
|
|
2595
2710
|
const stableTokenCount = this.#streamPrefixTokens?.length ?? 0;
|
|
2596
2711
|
if (stableText === undefined || stableTokenCount === 0 || !normalizedText.startsWith(stableText)) {
|
|
@@ -3594,6 +3709,41 @@ export class Markdown implements Component {
|
|
|
3594
3709
|
const indent = " ".repeat(depth);
|
|
3595
3710
|
// Use the list's start property (defaults to 1 for ordered lists)
|
|
3596
3711
|
const startNumber = token.start ?? 1;
|
|
3712
|
+
const signature =
|
|
3713
|
+
depth === 0 &&
|
|
3714
|
+
styleContext === undefined &&
|
|
3715
|
+
this.#defaultTextStyle === undefined &&
|
|
3716
|
+
this.#transientRenderCache &&
|
|
3717
|
+
!this.#renderingStablePrefix &&
|
|
3718
|
+
this.#lastScanCanStream &&
|
|
3719
|
+
canCacheMarkdownListItems(this.#theme)
|
|
3720
|
+
? this.#activeRenderSignature
|
|
3721
|
+
: undefined;
|
|
3722
|
+
const themeEpoch = signature === undefined ? undefined : getThemeEpoch();
|
|
3723
|
+
const previous = this.#listItemRowsCache;
|
|
3724
|
+
const reusable =
|
|
3725
|
+
signature !== undefined &&
|
|
3726
|
+
themeEpoch !== undefined &&
|
|
3727
|
+
previous !== undefined &&
|
|
3728
|
+
previous.width === width &&
|
|
3729
|
+
previous.themeEpoch === themeEpoch &&
|
|
3730
|
+
previous.ordered === token.ordered &&
|
|
3731
|
+
previous.start === token.start &&
|
|
3732
|
+
previous.loose === token.loose &&
|
|
3733
|
+
this.#signatureEquals(previous.signature, signature)
|
|
3734
|
+
? previous
|
|
3735
|
+
: undefined;
|
|
3736
|
+
let reusableCount = 0;
|
|
3737
|
+
if (reusable !== undefined) {
|
|
3738
|
+
while (
|
|
3739
|
+
reusableCount < token.items.length &&
|
|
3740
|
+
token.items[reusableCount]?.raw === reusable.raws[reusableCount]
|
|
3741
|
+
) {
|
|
3742
|
+
reusableCount++;
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
const raws: string[] | undefined = signature === undefined ? undefined : [];
|
|
3746
|
+
const rows: RenderedLine[][] | undefined = signature === undefined ? undefined : [];
|
|
3597
3747
|
const pushWrapped = (line: RenderedLine, firstPrefix: string, continuationPrefix: string): void => {
|
|
3598
3748
|
if (line.literalCode) {
|
|
3599
3749
|
const wrappedLiteralRows = wrapTextWithAnsi(line.text, Math.max(1, width));
|
|
@@ -3629,6 +3779,14 @@ export class Markdown implements Component {
|
|
|
3629
3779
|
|
|
3630
3780
|
for (let i = 0; i < token.items.length; i++) {
|
|
3631
3781
|
const item = token.items[i];
|
|
3782
|
+
if (i < reusableCount && reusable !== undefined && raws !== undefined && rows !== undefined) {
|
|
3783
|
+
const cached = reusable.rows[i]!;
|
|
3784
|
+
for (const row of cached) lines.push(row);
|
|
3785
|
+
raws.push(item.raw);
|
|
3786
|
+
rows.push(cached);
|
|
3787
|
+
continue;
|
|
3788
|
+
}
|
|
3789
|
+
const rowStart = lines.length;
|
|
3632
3790
|
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
|
|
3633
3791
|
const firstPrefix = indent + this.#theme.listBullet(bullet);
|
|
3634
3792
|
// Continuation rows align under the item text, so the hang matches the
|
|
@@ -3658,6 +3816,22 @@ export class Markdown implements Component {
|
|
|
3658
3816
|
} else {
|
|
3659
3817
|
lines.push(renderedLine(firstPrefix));
|
|
3660
3818
|
}
|
|
3819
|
+
if (raws !== undefined && rows !== undefined) {
|
|
3820
|
+
raws.push(item.raw);
|
|
3821
|
+
rows.push(lines.slice(rowStart));
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
if (signature !== undefined && themeEpoch !== undefined && raws !== undefined && rows !== undefined) {
|
|
3825
|
+
this.#listItemRowsCache = {
|
|
3826
|
+
signature,
|
|
3827
|
+
width,
|
|
3828
|
+
themeEpoch,
|
|
3829
|
+
ordered: token.ordered,
|
|
3830
|
+
start: token.start,
|
|
3831
|
+
loose: token.loose,
|
|
3832
|
+
raws,
|
|
3833
|
+
rows,
|
|
3834
|
+
};
|
|
3661
3835
|
}
|
|
3662
3836
|
|
|
3663
3837
|
return lines;
|
|
@@ -1991,7 +1991,13 @@ export class ModelHubComponent implements Component {
|
|
|
1991
1991
|
|
|
1992
1992
|
if (rolesView) {
|
|
1993
1993
|
const printable = extractPrintableText(data);
|
|
1994
|
-
|
|
1994
|
+
// Typing searches the catalog from the sidebar, and from the rows
|
|
1995
|
+
// while a query is live (the picker shows its search field only
|
|
1996
|
+
// then); otherwise the rows' letter commands own printable keys.
|
|
1997
|
+
const typed = printable !== undefined && printable.trim().length > 0;
|
|
1998
|
+
const editsQuery =
|
|
1999
|
+
this.#browser.query.length > 0 && (printable !== undefined || matchesKey(data, "backspace"));
|
|
2000
|
+
if ((this.#focus === "scope" && typed) || editsQuery) {
|
|
1995
2001
|
this.#setActiveEntry("all");
|
|
1996
2002
|
this.#focus = "list";
|
|
1997
2003
|
this.#browser.handleInput(data);
|
|
@@ -2434,9 +2440,14 @@ export class ModelHubComponent implements Component {
|
|
|
2434
2440
|
Math.max(0, active),
|
|
2435
2441
|
);
|
|
2436
2442
|
const { names, active: activePreset } = this.#presets();
|
|
2443
|
+
// p/P type into a live query instead of switching presets.
|
|
2444
|
+
const presetKeys =
|
|
2445
|
+
this.#browser.query.length > 0
|
|
2446
|
+
? formatKeyHints(["ctrl+left", "ctrl+right"])
|
|
2447
|
+
: `${formatKeyHints(["ctrl+left", "ctrl+right"])} · ${formatKeyHints(["p", "shift+p"])}`;
|
|
2437
2448
|
const preset =
|
|
2438
2449
|
names.length > 0
|
|
2439
|
-
? ` ${theme.fg("dim", "Preset:")} ${activePreset ? theme.fg("accent", activePreset) : theme.fg("muted", "custom")} ${theme.fg("dim",
|
|
2450
|
+
? ` ${theme.fg("dim", "Preset:")} ${activePreset ? theme.fg("accent", activePreset) : theme.fg("muted", "custom")} ${theme.fg("dim", presetKeys)}`
|
|
2440
2451
|
: "";
|
|
2441
2452
|
return truncateToWidth(
|
|
2442
2453
|
` ${theme.fg("dim", "Roles:")} ${track} ${theme.fg("dim", formatKeyHints(["alt+left", "alt+right"]))}${preset}`,
|
|
@@ -2741,6 +2752,10 @@ export class ModelHubComponent implements Component {
|
|
|
2741
2752
|
this.#presets().names.length > 0 ? ` · ${formatKeyHints(["ctrl+left", "ctrl+right"])} preset` : "";
|
|
2742
2753
|
return `${upDown} providers · ${enterRight} roles · ${altLeftRight} tabs${presets} · ${cancel} close`;
|
|
2743
2754
|
}
|
|
2755
|
+
// A live query takes printable keys and backspace, so the letter commands rest.
|
|
2756
|
+
if (this.#browser.query.length > 0) {
|
|
2757
|
+
return `${upDown} rows · ${enter} pick · type to search · ${left} providers · ${cancel} close`;
|
|
2758
|
+
}
|
|
2744
2759
|
const row = this.#rolesRows[this.#roleIndex];
|
|
2745
2760
|
const compaction =
|
|
2746
2761
|
this.#callbacks.onCompactionPointChange && this.#roleRowModel()
|
|
@@ -3228,7 +3243,9 @@ export class ModelHubComponent implements Component {
|
|
|
3228
3243
|
size: "lg",
|
|
3229
3244
|
layout: "rows",
|
|
3230
3245
|
preview: "side",
|
|
3231
|
-
|
|
3246
|
+
// The Roles rows' letter commands own printable keys until a query
|
|
3247
|
+
// is live, so the sheet offers no search field to type into.
|
|
3248
|
+
query: rolesView && this.#browser.query.length === 0 ? null : this.#browser.query,
|
|
3232
3249
|
cursor: this.#browser.cursor,
|
|
3233
3250
|
placeholder: "Search models…",
|
|
3234
3251
|
scopes: this.#pickerScopes(),
|
|
@@ -3486,8 +3503,15 @@ export class ModelHubComponent implements Component {
|
|
|
3486
3503
|
}
|
|
3487
3504
|
if (rolesView) {
|
|
3488
3505
|
const row = this.#rolesRows[this.#roleIndex];
|
|
3506
|
+
// A live query takes the letter keys: those buttons keep working but lose their keycaps.
|
|
3507
|
+
const searching = this.#browser.query.length > 0;
|
|
3489
3508
|
const roleAction = (action: RolesAction, label: string, key: string, primary = false) =>
|
|
3490
|
-
pickerAction(
|
|
3509
|
+
pickerAction(
|
|
3510
|
+
`roles:${action}`,
|
|
3511
|
+
label,
|
|
3512
|
+
searching && key !== "enter" ? undefined : key,
|
|
3513
|
+
primary ? { primary: true } : undefined,
|
|
3514
|
+
);
|
|
3491
3515
|
const actions: (TspPickerAction | undefined)[] = [];
|
|
3492
3516
|
const compaction =
|
|
3493
3517
|
this.#callbacks.onCompactionPointChange && this.#roleRowModel()
|
|
@@ -4042,6 +4066,17 @@ export class ModelHubComponent implements Component {
|
|
|
4042
4066
|
cancel("close"),
|
|
4043
4067
|
];
|
|
4044
4068
|
}
|
|
4069
|
+
// A live query takes printable keys and backspace, so the letter commands rest.
|
|
4070
|
+
if (this.#browser.query.length > 0) {
|
|
4071
|
+
return [
|
|
4072
|
+
upDown("rows"),
|
|
4073
|
+
keys("pick", "enter"),
|
|
4074
|
+
search,
|
|
4075
|
+
keys("providers", "left"),
|
|
4076
|
+
presetHint,
|
|
4077
|
+
cancel("close"),
|
|
4078
|
+
];
|
|
4079
|
+
}
|
|
4045
4080
|
const row = this.#rolesRows[this.#roleIndex];
|
|
4046
4081
|
const compaction =
|
|
4047
4082
|
this.#callbacks.onCompactionPointChange && this.#roleRowModel() ? keys("compaction limit", "k") : undefined;
|
|
@@ -1330,7 +1330,11 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
|
|
|
1330
1330
|
title?: string;
|
|
1331
1331
|
/** Fixed scope label, or false to omit the scope suffix. */
|
|
1332
1332
|
scopeLabel?: string | false;
|
|
1333
|
-
/**
|
|
1333
|
+
/**
|
|
1334
|
+
* Show each session's working directory in the list. Defaults to on when
|
|
1335
|
+
* the session files live in more than one directory (e.g. the folder scope
|
|
1336
|
+
* merging a repository's worktrees).
|
|
1337
|
+
*/
|
|
1334
1338
|
showCwd?: boolean;
|
|
1335
1339
|
/**
|
|
1336
1340
|
* Reads the live terminal height so the visible window fits the viewport.
|
|
@@ -1374,6 +1378,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1374
1378
|
#onRequestRender?: () => void;
|
|
1375
1379
|
readonly #loadAllSessions?: () => Promise<T[]>;
|
|
1376
1380
|
#folderSessions: T[];
|
|
1381
|
+
readonly #folderShowCwd: boolean;
|
|
1377
1382
|
#globalSessions: T[] | null = null;
|
|
1378
1383
|
#scope: "folder" | "all" = "folder";
|
|
1379
1384
|
#toggling = false;
|
|
@@ -1440,6 +1445,9 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1440
1445
|
this.#onDelete = options.onDelete;
|
|
1441
1446
|
this.#loadAllSessions = options.loadAllSessions;
|
|
1442
1447
|
this.#folderSessions = sessions;
|
|
1448
|
+
// Storage directory, not recorded cwd: one folder's sessions may record
|
|
1449
|
+
// symlink aliases of the same path, which must not turn the column on.
|
|
1450
|
+
this.#folderShowCwd = options.showCwd ?? new Set(sessions.map(session => path.dirname(session.path))).size > 1;
|
|
1443
1451
|
this.#globalSessions = options.allSessions ?? null;
|
|
1444
1452
|
this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
|
|
1445
1453
|
this.#fillHeight = options.fillHeight ?? false;
|
|
@@ -1457,7 +1465,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1457
1465
|
// projects' history (issue #3099).
|
|
1458
1466
|
this.#sessionList = new SessionList(
|
|
1459
1467
|
sessions,
|
|
1460
|
-
|
|
1468
|
+
this.#folderShowCwd,
|
|
1461
1469
|
options.historyMatcher,
|
|
1462
1470
|
options.getTerminalRows,
|
|
1463
1471
|
options.pinnedIds,
|
|
@@ -1530,7 +1538,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
|
|
|
1530
1538
|
this.#sessionList.setSessions(global, true);
|
|
1531
1539
|
} else {
|
|
1532
1540
|
this.#scope = "folder";
|
|
1533
|
-
this.#sessionList.setSessions(this.#folderSessions,
|
|
1541
|
+
this.#sessionList.setSessions(this.#folderSessions, this.#folderShowCwd);
|
|
1534
1542
|
}
|
|
1535
1543
|
this.title = this.#headerLabel();
|
|
1536
1544
|
this.#onRequestRender?.();
|
package/src/prompt/tips.txt
CHANGED
|
@@ -30,4 +30,5 @@ Turn on `/advisor` to attach a second model that reviews every turn and quietly
|
|
|
30
30
|
Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
|
|
31
31
|
Press {key:shift+tab} to cycle through reasoning effort levels
|
|
32
32
|
omp can reconfigure and extend itself — ask any questions you have!
|
|
33
|
-
Lint/type errors piling up? `omp cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — {key:escape} cancels
|
|
33
|
+
Lint/type errors piling up? `omp cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — {key:escape} cancels
|
|
34
|
+
[NO-TERN] Psst, omp has a natural habitat 🐦 [tern](https://scl.so/tern)
|
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
|
|