@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 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
- /** Show each session's working directory in the list. */
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.5",
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.5",
45
- "@oh-my-pi/pi-agent-core": "18.8.5",
46
- "@oh-my-pi/pi-ai": "18.8.5",
47
- "@oh-my-pi/pi-catalog": "18.8.5",
48
- "@oh-my-pi/pi-natives": "18.8.5",
49
- "@oh-my-pi/pi-utils": "18.8.5",
50
- "@oh-my-pi/pi-wire": "18.8.5",
51
- "@oh-my-pi/snapcompact": "18.8.5"
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 ? { ...base, resolveLink: (href: string) => snapshot.get(href) } : base;
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 & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
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 = markdownParser.lexer(refDefText);
2115
- // HAS_REF_DEF sees top-level definition lines only. A definition nested
2116
- // in a quote or list item still registers for the whole document and
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 = markdownParser.lexer(text);
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 && Object.keys(tokens.links).length === 0) {
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
- if (this.#focus === "scope" && printable !== undefined && printable.trim().length > 0) {
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", `${formatKeyHints(["ctrl+left", "ctrl+right"])} · ${formatKeyHints(["p", "shift+p"])}`)}`
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
- query: this.#browser.query,
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(`roles:${action}`, label, key, primary ? { primary: true } : undefined);
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
- /** Show each session's working directory in the list. */
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
- options.showCwd ?? false,
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, false);
1541
+ this.#sessionList.setSessions(this.#folderSessions, this.#folderShowCwd);
1534
1542
  }
1535
1543
  this.title = this.#headerLabel();
1536
1544
  this.#onRequestRender?.();
@@ -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)
@@ -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