@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 CHANGED
@@ -2,6 +2,29 @@
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
+
17
+ ## [18.8.5] - 2026-10-08
18
+
19
+ ### Added
20
+
21
+ - Added a **Compacts at** fact to the model hub preview and a Roles-view **Compaction limit** action (`k`) that edits the selected row's model limit through the new `ModelBrowserSource.compactionPointFor` and `ModelHubCallbacks.onCompactionPointChange` hooks ([#14952](https://github.com/can1357/oh-my-pi/pull/14952) by [@H4vC](https://github.com/H4vC))
22
+
23
+ ### Fixed
24
+
25
+ - Fixed `/usage` dashboard cards reordering their limit rows by usage; rows now keep the provider's window order (e.g. 5 hour → weekly → monthly) ([#14953](https://github.com/can1357/oh-my-pi/pull/14953) by [@H4vC](https://github.com/H4vC))
26
+ - Fixed Cmd+A, Cmd+C, Cmd+X and Shift+arrow selection doing nothing in the Tern prompt while Vim mode is in Insert mode ([#14954](https://github.com/can1357/oh-my-pi/pull/14954) by [@H4vC](https://github.com/H4vC))
27
+
5
28
  ## [18.8.4] - 2026-10-08
6
29
 
7
30
  ### Changed
@@ -4,7 +4,7 @@ import type { ReactionTarget } from "./reaction.js";
4
4
  import { type DescribeContext, type NativeNode, type NativeUiEvent } from "../native/node.js";
5
5
  /** How a user bubble styles its prose and chips (see {@link userBubbleColor}). */
6
6
  export interface UserBubbleOptions {
7
- /** Materialized `file://` targets per attached image, indexed by chip number. */
7
+ /** Filesystem paths for attached image chips, indexed by chip number. */
8
8
  imageLinks?: readonly (string | undefined)[];
9
9
  /** The message's attached images in chip order (`#1` first); a native bubble shows them. */
10
10
  images?: readonly ImageContent[];
@@ -37,6 +37,17 @@ export interface ResolvedModelRoleValue {
37
37
  explicitThinkingLevel: boolean;
38
38
  warning?: string;
39
39
  }
40
+ /** Where auto-compaction triggers for one model, as the model hub's preview shows it (edited from the Roles view). */
41
+ export interface ModelCompactionPoint {
42
+ /** Context tokens that trigger auto-compaction; undefined when auto-compaction is off or the window is unknown. */
43
+ tokens: number | undefined;
44
+ /** The configured percentage of the window, when the trigger is percent-based. */
45
+ percent?: number;
46
+ /** What sets it: the matching `compaction.modelThresholds` key, `global`, or `default`. */
47
+ source: string;
48
+ /** The model's own exact entry as editable text (`90k`, `80%`); absent when it has none. */
49
+ draft?: string;
50
+ }
40
51
  /** Host-provided preferences and model-role resolution for the browser. */
41
52
  export interface ModelBrowserSource extends ModelRoleLookup {
42
53
  /**
@@ -58,6 +69,8 @@ export interface ModelBrowserSource extends ModelRoleLookup {
58
69
  getRoleInfo(role: string): ModelBrowserRoleInfo;
59
70
  defaultRoleChain(role: string): string[];
60
71
  resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
72
+ /** Auto-compaction point for `model`; absent hosts show no compaction row in the preview. */
73
+ compactionPointFor?(model: Model): ModelCompactionPoint | undefined;
61
74
  }
62
75
  /** Read-only catalog surface consumed by model browsers. */
63
76
  export interface ModelBrowserRegistry {
@@ -64,6 +64,12 @@ export interface ModelHubCallbacks {
64
64
  onSwitchPreset?: (name: string) => void | Promise<void>;
65
65
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
66
66
  onCycleOrderChange?: (order: string[]) => void;
67
+ /**
68
+ * Persist typed text as `model`'s own compaction point: a token count (`90000`,
69
+ * `90k`, `1M`), a percentage (`80%`), or empty to reset. Returns an error
70
+ * message for input it rejects, which keeps the field open.
71
+ */
72
+ onCompactionPointChange?: (model: Model, input: string) => string | undefined;
67
73
  onCancel: () => void;
68
74
  }
69
75
  export interface ModelHubOptions {
@@ -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.
@@ -33,7 +33,7 @@ export interface ProviderCard {
33
33
  /** Number of represented accounts, including unavailable usage lookups. */
34
34
  accounts: number;
35
35
  unavailableAccounts: string[];
36
- /** Window rows sorted most-pressing first. */
36
+ /** Window rows in provider-declared order (e.g. 5h → weekly → monthly); the fullest CARD_MAX_WINDOWS lead. */
37
37
  windows: CardWindowRow[];
38
38
  /** True when every account reports no limits (e.g. enterprise plans). */
39
39
  unlimited: boolean;
@@ -52,8 +52,10 @@ export interface ProviderCard {
52
52
  * Collapse usage reports into one compact card per provider: limits grouped by
53
53
  * quota bucket (label + window), each bucket showing the mean used fraction
54
54
  * across accounts (matching the classic report's aggregate "% free") with the
55
- * most-used account's reset countdown. Cards sort most-pressing first so
56
- * what's burning is on top-left; fully idle providers collapse into a tick.
55
+ * most-used account's reset countdown. Rows keep the provider's declared window
56
+ * order so 5h → week → month reads the same every time; cards sort by their
57
+ * fullest window so what's burning is on top-left; fully idle providers
58
+ * collapse into a tick.
57
59
  */
58
60
  export declare function buildProviderCards(reports: UsageReport[], nowMs: number, unavailableAccounts?: readonly UnavailableUsageAccount[]): ProviderCard[];
59
61
  /** GitHub-style week-per-column heatmap grid derived from daily activity. */
@@ -118,8 +118,8 @@ export declare class CustomEditor extends Editor {
118
118
  * Numbered by a per-draft monotonic counter so a deleted chip never recycles its number
119
119
  * (labels key the atom table). */
120
120
  pendingTexts: TextAttachment[];
121
- /** Host-wired producer of per-image `file://` links (session blob store); drives clickable
122
- * chip tokens for restored drafts (esc-esc, `/tree`, branch). */
121
+ /** Host-wired producer of per-image chip targets (a file on disk, or a session blob copy);
122
+ * drives clickable chip tokens for restored drafts (esc-esc, `/tree`, branch). */
123
123
  draftImageLinkMaterializer?: (images: readonly ImageContent[]) => Promise<(string | undefined)[] | undefined>;
124
124
  /**
125
125
  * The host {@link TUI}, captured when a plugin constructs this editor through
@@ -4,7 +4,8 @@
4
4
  * to the session's `local://` root, and generated video contact-sheet previews.
5
5
  * Symbol metadata stays out of serialized/model-bound image data while traveling
6
6
  * with the draft object, until AgentSession creates the hidden companion message
7
- * that tells the model the path (and link materialization prefers it over a blob copy).
7
+ * that tells the model the path. The model keeps `local://` references across
8
+ * `/move`; chips for internal URLs instead use a stable blob copy.
8
9
  */
9
10
  import type { ImageContent } from "@oh-my-pi/pi-ai";
10
11
  /** How an image attachment's backing file entered the session. */
@@ -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",
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.4",
45
- "@oh-my-pi/pi-agent-core": "18.8.4",
46
- "@oh-my-pi/pi-ai": "18.8.4",
47
- "@oh-my-pi/pi-catalog": "18.8.4",
48
- "@oh-my-pi/pi-natives": "18.8.4",
49
- "@oh-my-pi/pi-utils": "18.8.4",
50
- "@oh-my-pi/pi-wire": "18.8.4",
51
- "@oh-my-pi/snapcompact": "18.8.4"
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
  }
@@ -57,7 +57,7 @@ const OSC133_ZONE_CLOSE = OSC133_ZONE_END + OSC133_COMMAND_START + OSC133_COMMAN
57
57
 
58
58
  /** How a user bubble styles its prose and chips (see {@link userBubbleColor}). */
59
59
  export interface UserBubbleOptions {
60
- /** Materialized `file://` targets per attached image, indexed by chip number. */
60
+ /** Filesystem paths for attached image chips, indexed by chip number. */
61
61
  imageLinks?: readonly (string | undefined)[];
62
62
  /** The message's attached images in chip order (`#1` first); a native bubble shows them. */
63
63
  images?: readonly ImageContent[];
@@ -74,9 +74,12 @@ export interface NativeEditorLayout {
74
74
  readonly caret: string;
75
75
  }
76
76
 
77
- /** Vim mode as the TSP `editor.mode` label. */
78
- const VIM_MODE_LABELS: Record<VimMode, string> = {
79
- insert: "INSERT",
77
+ /**
78
+ * Vim mode as the TSP `editor.mode` label. Insert carries none: Insert edits like a plain field,
79
+ * and a host such as Tern stops native editing (selection, ⌘A/⌘C/⌘X) for any labelled mode.
80
+ */
81
+ const VIM_MODE_LABELS: Record<VimMode, string | undefined> = {
82
+ insert: undefined,
80
83
  normal: "NORMAL",
81
84
  visual: "VISUAL",
82
85
  "visual-line": "VISUAL",
@@ -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;
@@ -122,6 +122,18 @@ export interface ResolvedModelRoleValue {
122
122
  warning?: string;
123
123
  }
124
124
 
125
+ /** Where auto-compaction triggers for one model, as the model hub's preview shows it (edited from the Roles view). */
126
+ export interface ModelCompactionPoint {
127
+ /** Context tokens that trigger auto-compaction; undefined when auto-compaction is off or the window is unknown. */
128
+ tokens: number | undefined;
129
+ /** The configured percentage of the window, when the trigger is percent-based. */
130
+ percent?: number;
131
+ /** What sets it: the matching `compaction.modelThresholds` key, `global`, or `default`. */
132
+ source: string;
133
+ /** The model's own exact entry as editable text (`90k`, `80%`); absent when it has none. */
134
+ draft?: string;
135
+ }
136
+
125
137
  /** Host-provided preferences and model-role resolution for the browser. */
126
138
  export interface ModelBrowserSource extends ModelRoleLookup {
127
139
  /**
@@ -143,6 +155,8 @@ export interface ModelBrowserSource extends ModelRoleLookup {
143
155
  getRoleInfo(role: string): ModelBrowserRoleInfo;
144
156
  defaultRoleChain(role: string): string[];
145
157
  resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
158
+ /** Auto-compaction point for `model`; absent hosts show no compaction row in the preview. */
159
+ compactionPointFor?(model: Model): ModelCompactionPoint | undefined;
146
160
  }
147
161
 
148
162
  /** Read-only catalog surface consumed by model browsers. */
@@ -2061,7 +2075,7 @@ export class ModelBrowser implements Component {
2061
2075
  pickerPreview(mode: "full" | "compact", current?: string): readonly NativeChild[] {
2062
2076
  const selected = this.getSelected();
2063
2077
  const item = selected && !this.#isDisabled(selected) ? selected : undefined;
2064
- const key = `${mode}\0${current ?? ""}`;
2078
+ const key = `${mode}\0${current ?? ""}\0${this.#settings.revision}`;
2065
2079
  const memo = this.#pickerPreview;
2066
2080
  if (
2067
2081
  memo !== undefined &&
@@ -2155,6 +2169,17 @@ export class ModelBrowser implements Component {
2155
2169
  if (v) facts.push({ k: [span(k, "muted")], v: [span(v, "mono")] });
2156
2170
  };
2157
2171
  fact("Context", ctx > 0 ? ctx.toLocaleString("en-US") : undefined);
2172
+ const compaction = this.#settings.compactionPointFor?.(model);
2173
+ if (compaction) {
2174
+ const value =
2175
+ compaction.tokens === undefined
2176
+ ? "off"
2177
+ : `${compaction.tokens.toLocaleString("en-US")}${compaction.percent !== undefined ? ` · ${compaction.percent}%` : ""}`;
2178
+ facts.push({
2179
+ k: [span("Compacts at", "muted")],
2180
+ v: [span(value, "mono"), span(` · ${compaction.source}`, "dim")],
2181
+ });
2182
+ }
2158
2183
  fact("Max output", out > 0 ? out.toLocaleString("en-US") : undefined);
2159
2184
  fact("Price", isFreeModel(model) ? "free" : `${previewPrice(model)} per M`);
2160
2185
  fact("Speed", speed.length > 0 ? speed.join(" · ") : undefined);