@oh-my-pi/pi-tui 18.4.2 → 18.4.3

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/chat/thinking-display.d.ts +2 -0
  3. package/dist/types/components/editor.d.ts +5 -0
  4. package/dist/types/loop-watchdog.d.ts +9 -0
  5. package/dist/types/overlays/model-picker.d.ts +2 -1
  6. package/dist/types/setup/scenes/sign-in.d.ts +9 -9
  7. package/dist/types/setup/scenes/types.d.ts +0 -26
  8. package/dist/types/stdin-buffer.d.ts +10 -7
  9. package/dist/types/terminal.d.ts +9 -0
  10. package/dist/types/tools/web-search-types.d.ts +90 -0
  11. package/dist/types/tools/web-search.d.ts +1 -168
  12. package/package.json +9 -9
  13. package/src/chat/assistant-message.ts +192 -40
  14. package/src/chat/thinking-display.ts +73 -11
  15. package/src/chrome/transcript-container.ts +102 -17
  16. package/src/components/editor.ts +23 -9
  17. package/src/components/markdown.ts +110 -56
  18. package/src/loop-watchdog.ts +38 -10
  19. package/src/overlays/model-picker.ts +9 -6
  20. package/src/overlays/settings-selector.ts +2 -10
  21. package/src/setup/scenes/sign-in.ts +17 -12
  22. package/src/setup/scenes/types.ts +0 -27
  23. package/src/setup/wizard.ts +1 -1
  24. package/src/status-line/component.ts +16 -2
  25. package/src/stdin-buffer.ts +27 -21
  26. package/src/terminal.ts +23 -9
  27. package/src/theme/mermaid-cache.ts +10 -1
  28. package/src/theme/tui-adapters.ts +14 -1
  29. package/src/tools/bash.ts +53 -13
  30. package/src/tools/edit.ts +110 -29
  31. package/src/tools/streaming-output.ts +79 -16
  32. package/src/tools/web-search-types.ts +99 -0
  33. package/src/tools/web-search.ts +1 -172
  34. package/src/tui.ts +1 -1
  35. package/dist/types/setup/scenes/providers.d.ts +0 -3
  36. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  37. package/src/setup/scenes/providers.ts +0 -110
  38. package/src/setup/scenes/web-search.ts +0 -165
@@ -880,7 +880,10 @@ for (const table of [Lexer.rules.block.normal, Lexer.rules.block.gfm]) {
880
880
 
881
881
  const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
882
882
  const RENDER_CACHE_MAX_SIZE = 4 * 1024 * 1024;
883
- const RENDER_CACHE_MAX_ENTRY_SIZE = 256 * 1024;
883
+ // Entry size counts the key (it embeds the whole normalized source) plus the
884
+ // rendered rows, so this admits roughly the same documents a rows-only 256 KiB
885
+ // cap did while the aggregate bound covers everything the entry retains.
886
+ const RENDER_CACHE_MAX_ENTRY_SIZE = 512 * 1024;
884
887
  const EMPTY_RENDER_LINES: readonly string[] = [];
885
888
 
886
889
  interface RenderedLine {
@@ -900,7 +903,7 @@ const renderCache = new LRUCache<string, readonly string[]>({
900
903
  max: RENDER_CACHE_MAX,
901
904
  maxSize: RENDER_CACHE_MAX_SIZE,
902
905
  maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
903
- sizeCalculation: renderedLinesCacheSize,
906
+ sizeCalculation: (lines, key) => renderedLinesCacheSize(lines) + key.length,
904
907
  });
905
908
 
906
909
  function renderedLinesCacheSize(lines: readonly string[]): number {
@@ -909,6 +912,15 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
909
912
  return Math.max(1, size);
910
913
  }
911
914
 
915
+ /**
916
+ * Append `src` onto `dst` element-wise. `dst.push(...src)` passes every row as
917
+ * a call argument, which throws past the engine's argument limit on very long
918
+ * documents.
919
+ */
920
+ function appendLines(dst: string[], src: readonly string[]): void {
921
+ for (let i = 0; i < src.length; i++) dst.push(src[i]!);
922
+ }
923
+
912
924
  // ---------------------------------------------------------------------------
913
925
  // Fast-tail (B+) hazard gates
914
926
  // ---------------------------------------------------------------------------
@@ -1661,7 +1673,9 @@ interface RenderSignature {
1661
1673
  interface StreamPrefixLineCache extends RenderSignature {
1662
1674
  text: string;
1663
1675
  tokenCount: number;
1664
- lines: readonly string[];
1676
+ // Private to the cache and never handed to callers (each frame copies it
1677
+ // into a fresh output array), so an advancing prefix appends in place.
1678
+ lines: string[];
1665
1679
  }
1666
1680
  /**
1667
1681
  * Per-token row cache for the *unfrozen tail* (PoC H). The tail re-lexes every
@@ -1717,7 +1731,9 @@ interface TailRenderRecorder {
1717
1731
  interface StreamingHighlightCache extends RenderSignature {
1718
1732
  lang: string | undefined;
1719
1733
  text: string;
1720
- lines: readonly string[];
1734
+ // Appended in place as the fence grows. Only ever returned to
1735
+ // #renderCodeBodyLines, which reads it synchronously and keeps no reference.
1736
+ lines: string[];
1721
1737
  stream: HighlightStreamSession;
1722
1738
  }
1723
1739
 
@@ -1882,10 +1898,7 @@ export class Markdown implements Component {
1882
1898
  // Blank replacement: render() early-returns before #lexTokens can see
1883
1899
  // the non-append edit, so drop the frozen stream state here or it
1884
1900
  // outlives the content it indexed.
1885
- this.#streamPrefixText = undefined;
1886
- this.#streamPrefixTokens = undefined;
1887
- this.#streamPrefixLineCache = undefined;
1888
- this.#tailRowCache = undefined;
1901
+ this.#dropStreamPrefix();
1889
1902
  // B+: the captured fast-path rows index the replaced content — drop
1890
1903
  // the recipe so a fresh stream cannot splice onto stale rows.
1891
1904
  this.#fastTail = undefined;
@@ -1927,6 +1940,17 @@ export class Markdown implements Component {
1927
1940
  // source — drop the fast-path recipe so stale rows cannot be served
1928
1941
  // across the transition.
1929
1942
  this.#fastTail = undefined;
1943
+ if (!next) {
1944
+ // Finalize: the prefix row cache, tail row cache and open-fence
1945
+ // highlight stream are read only by transient renders, and each
1946
+ // rebuilds from scratch if the block ever streams again — release
1947
+ // them so settled blocks stop pinning rows and a native parser.
1948
+ // The frozen lex prefix survives only until the next final-mode
1949
+ // render consumes it (see #lexTokens / the L2 hit path).
1950
+ this.#streamPrefixLineCache = undefined;
1951
+ this.#tailRowCache = undefined;
1952
+ this.#streamingHighlightCache = undefined;
1953
+ }
1930
1954
  this.invalidate();
1931
1955
  }
1932
1956
 
@@ -1964,8 +1988,14 @@ export class Markdown implements Component {
1964
1988
  // happens exactly when the CR/ref-def trigger behind a false verdict
1965
1989
  // may have been deleted — never left stale, and never re-scanned on
1966
1990
  // frames where the memo is sound.
1991
+ const appendGrowth = this.#lastScanValid && this.#appendOnlySinceLastScan && text.length > this.#lastScanLength;
1992
+ // Final mode keeps the frozen prefix only while the text keeps growing
1993
+ // by appends (a stream rendered without the transient flag). A one-shot
1994
+ // or just-finalized render still lexes against any existing prefix but
1995
+ // then drops it, so settled blocks do not pin a token tree.
1996
+ const retainPrefix = this.#transientRenderCache || appendGrowth;
1967
1997
  let canStream: boolean;
1968
- if (this.#lastScanValid && this.#appendOnlySinceLastScan && text.length > this.#lastScanLength) {
1998
+ if (appendGrowth) {
1969
1999
  const delta = text.slice(this.#lastScanLength);
1970
2000
  if (
1971
2001
  !delta.includes("[") &&
@@ -1990,21 +2020,27 @@ export class Markdown implements Component {
1990
2020
  if (canStream && hasPrefix) {
1991
2021
  const tailTokens = lexDocument(refDefText);
1992
2022
  const tokens = [...prefixTokens, ...tailTokens];
1993
- this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2023
+ if (retainPrefix) this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2024
+ else this.#dropStreamPrefix();
1994
2025
  return tokens;
1995
2026
  }
1996
2027
  const tokens = lexDocument(text);
1997
- if (canStream) {
2028
+ if (canStream && retainPrefix) {
1998
2029
  this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
1999
2030
  } else {
2000
- this.#streamPrefixText = undefined;
2001
- this.#streamPrefixTokens = undefined;
2002
- this.#streamPrefixLineCache = undefined;
2003
- this.#tailRowCache = undefined;
2031
+ this.#dropStreamPrefix();
2004
2032
  }
2005
2033
  return tokens;
2006
2034
  }
2007
2035
 
2036
+ /** Drop the frozen lex prefix and the transient row caches keyed on it. */
2037
+ #dropStreamPrefix(): void {
2038
+ this.#streamPrefixText = undefined;
2039
+ this.#streamPrefixTokens = undefined;
2040
+ this.#streamPrefixLineCache = undefined;
2041
+ this.#tailRowCache = undefined;
2042
+ }
2043
+
2008
2044
  // Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
2009
2045
  // (complete and immutable under append-only growth) so the next streaming
2010
2046
  // render re-lexes only the unfrozen tail. Caller guarantees no CR / no
@@ -2032,12 +2068,7 @@ export class Markdown implements Component {
2032
2068
  return;
2033
2069
  }
2034
2070
 
2035
- if (!opts.preserveExisting) {
2036
- this.#streamPrefixText = undefined;
2037
- this.#streamPrefixTokens = undefined;
2038
- this.#streamPrefixLineCache = undefined;
2039
- this.#tailRowCache = undefined;
2040
- }
2071
+ if (!opts.preserveExisting) this.#dropStreamPrefix();
2041
2072
  }
2042
2073
 
2043
2074
  render(width: number): readonly string[] {
@@ -2166,9 +2197,12 @@ export class Markdown implements Component {
2166
2197
  );
2167
2198
  }
2168
2199
  // Splice onto the previous frame's rows (new array — parent may
2169
- // hold the old one).
2200
+ // hold the old one). One copy of the head, then the few new and
2201
+ // trailing rows, instead of spreading two intermediate slices.
2170
2202
  const prev = recipe.lines;
2171
- const fastResult = [...prev.slice(0, recipe.rowStart), ...fastRows, ...prev.slice(recipe.rowEnd)];
2203
+ const fastResult = prev.slice(0, recipe.rowStart);
2204
+ appendLines(fastResult, fastRows);
2205
+ for (let i = recipe.rowEnd; i < prev.length; i++) fastResult.push(prev[i]!);
2172
2206
  this.#cachedText = this.#text;
2173
2207
  this.#cachedWidth = width;
2174
2208
  this.#cachedLines = fastResult;
@@ -2220,6 +2254,10 @@ export class Markdown implements Component {
2220
2254
  cacheKey = this.#renderCacheKey(normalizedText, signature);
2221
2255
  const cached = renderCache.get(cacheKey);
2222
2256
  if (cached !== undefined) {
2257
+ // A final-mode hit right after finalize (or a non-append edit)
2258
+ // never reaches #lexTokens, which would otherwise consume and drop
2259
+ // the frozen lex prefix — release it here instead.
2260
+ if (!this.#appendOnlySinceLastScan) this.#dropStreamPrefix();
2223
2261
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
2224
2262
  this.#cachedText = this.#text;
2225
2263
  this.#cachedWidth = width;
@@ -2241,8 +2279,10 @@ export class Markdown implements Component {
2241
2279
  }
2242
2280
  const emptyLines = this.#renderEmptyPaddingLines(signature);
2243
2281
 
2244
- // Combine top padding, content, and bottom padding
2245
- const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
2282
+ // Combine top padding, content, and bottom padding. contentLines is a
2283
+ // fresh per-render array (never cache-owned), so without vertical
2284
+ // padding it is handed out as-is rather than copied again.
2285
+ const rawResult = emptyLines.length === 0 ? contentLines : [...emptyLines, ...contentLines, ...emptyLines];
2246
2286
  const result = rawResult.length > 0 ? rawResult : [""];
2247
2287
 
2248
2288
  // Update caches and hand the array out by reference. Callers must not
@@ -2329,11 +2369,15 @@ export class Markdown implements Component {
2329
2369
  return this.#renderStreamingTail(tokens, 0, contentWidth, signature);
2330
2370
  }
2331
2371
 
2332
- const contentLines: string[] = [];
2372
+ // Prefix rows live in #streamPrefixLineCache.lines, which is private to
2373
+ // the cache: an advancing prefix appends its newly frozen rows in place
2374
+ // instead of re-snapshotting, and each frame's output is one fresh copy
2375
+ // of prefix + tail, so no array handed to a caller is ever mutated.
2333
2376
  const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, stableText, signature);
2377
+ let prefixLines: string[] = [];
2334
2378
  let renderedUntil = 0;
2335
2379
  if (reusablePrefix && reusablePrefix.tokenCount <= stableTokenCount) {
2336
- contentLines.push(...reusablePrefix.lines);
2380
+ prefixLines = reusablePrefix.lines;
2337
2381
  renderedUntil = reusablePrefix.tokenCount;
2338
2382
  }
2339
2383
 
@@ -2341,13 +2385,14 @@ export class Markdown implements Component {
2341
2385
  // Stable tokens render with full fidelity (syntax highlighting on)
2342
2386
  // so these cached rows byte-match the finalized render.
2343
2387
  this.#renderingStablePrefix = true;
2388
+ let frozenRows: string[];
2344
2389
  try {
2345
- contentLines.push(
2346
- ...this.#renderContentLines(tokens, renderedUntil, stableTokenCount, contentWidth, signature),
2347
- );
2390
+ frozenRows = this.#renderContentLines(tokens, renderedUntil, stableTokenCount, contentWidth, signature);
2348
2391
  } finally {
2349
2392
  this.#renderingStablePrefix = false;
2350
2393
  }
2394
+ if (prefixLines.length === 0) prefixLines = frozenRows;
2395
+ else appendLines(prefixLines, frozenRows);
2351
2396
  renderedUntil = stableTokenCount;
2352
2397
  }
2353
2398
 
@@ -2355,14 +2400,11 @@ export class Markdown implements Component {
2355
2400
  ...signature,
2356
2401
  text: stableText,
2357
2402
  tokenCount: stableTokenCount,
2358
- lines: contentLines.slice(),
2403
+ lines: prefixLines,
2359
2404
  };
2360
2405
 
2361
- if (renderedUntil < tokens.length) {
2362
- contentLines.push(...this.#renderStreamingTail(tokens, renderedUntil, contentWidth, signature));
2363
- }
2364
-
2365
- return contentLines;
2406
+ if (renderedUntil >= tokens.length) return prefixLines.slice();
2407
+ return prefixLines.concat(this.#renderStreamingTail(tokens, renderedUntil, contentWidth, signature));
2366
2408
  }
2367
2409
 
2368
2410
  #matchingStreamPrefixLineCache(
@@ -2403,7 +2445,7 @@ export class Markdown implements Component {
2403
2445
  if (cache !== undefined) {
2404
2446
  spliceEnd = this.#tailSpliceEnd(cache, start, signature, tokens);
2405
2447
  for (let i = start; i < spliceEnd; i++) {
2406
- out.push(...cache.rows[i - start]!);
2448
+ appendLines(out, cache.rows[i - start]!);
2407
2449
  }
2408
2450
  }
2409
2451
 
@@ -2416,7 +2458,7 @@ export class Markdown implements Component {
2416
2458
  nextTypes: new Array(tokens.length - spliceEnd).fill(undefined),
2417
2459
  };
2418
2460
  const fresh = this.#renderContentLines(tokens, spliceEnd, tokens.length, contentWidth, signature, recorder);
2419
- out.push(...fresh);
2461
+ const result = out.length === 0 ? fresh : out.concat(fresh);
2420
2462
 
2421
2463
  // Refresh the cache: keep entries for spliced tokens (their raws stay
2422
2464
  // valid), overlay the fresh entries, and re-derive the contiguous
@@ -2453,7 +2495,7 @@ export class Markdown implements Component {
2453
2495
  raws,
2454
2496
  nextTypes,
2455
2497
  };
2456
- return out;
2498
+ return result;
2457
2499
  }
2458
2500
 
2459
2501
  // Longest cache-spliceable prefix: every cached row from `start` up to
@@ -2658,7 +2700,7 @@ export class Markdown implements Component {
2658
2700
  // colors reach completed rows immediately; only the trailing partial
2659
2701
  // line stays unhighlighted.
2660
2702
  const lineEnd = tokenText.lastIndexOf("\n");
2661
- const completedLines = lineEnd >= 0 ? this.#highlightStreamingLines(tokenText.slice(0, lineEnd), lang) : null;
2703
+ const completedLines = lineEnd >= 0 ? this.#highlightStreamingLines(tokenText, lineEnd, lang) : null;
2662
2704
  if (completedLines) {
2663
2705
  for (const hlLine of completedLines) {
2664
2706
  addBodyLine(hlLine);
@@ -2708,21 +2750,28 @@ export class Markdown implements Component {
2708
2750
  }
2709
2751
 
2710
2752
  /**
2711
- * Highlight the completed (newline-terminated) prefix of a streaming code
2712
- * fence. Uses a stateful per-fence highlight stream so each render pushes
2713
- * only the newly completed lines, with output byte-identical to the
2714
- * whole-block `highlightCode` the finalized render performs. Returns null
2715
- * when no stream is available for `lang` (caller falls back to plain
2716
- * code-block styling).
2753
+ * Highlight the completed (newline-terminated) prefix `tokenText[0,
2754
+ * completedEnd)` of a streaming code fence. Uses a stateful per-fence
2755
+ * highlight stream so each render pushes only the newly completed lines,
2756
+ * with output byte-identical to the whole-block `highlightCode` the
2757
+ * finalized render performs. Returns null when no stream is available for
2758
+ * `lang` (caller falls back to plain code-block styling).
2717
2759
  */
2718
- #highlightStreamingLines(completedText: string, lang: string | undefined): readonly string[] | null {
2760
+ #highlightStreamingLines(
2761
+ tokenText: string,
2762
+ completedEnd: number,
2763
+ lang: string | undefined,
2764
+ ): readonly string[] | null {
2719
2765
  const signature = this.#activeRenderSignature;
2720
2766
  const cache = this.#streamingHighlightCache;
2767
+ // Cheap cursor/length and signature gates first; the prefix compare
2768
+ // (the only O(fence) check, needed because a different fence can reach
2769
+ // this cache) runs last.
2721
2770
  if (
2722
2771
  signature &&
2723
2772
  cache &&
2724
- completedText.startsWith(cache.text) &&
2725
- (cache.text.length === completedText.length || completedText.charCodeAt(cache.text.length) === 0x0a) &&
2773
+ cache.text.length <= completedEnd &&
2774
+ (cache.text.length === completedEnd || tokenText.charCodeAt(cache.text.length) === 0x0a) &&
2726
2775
  cache.lang === lang &&
2727
2776
  cache.width === signature.width &&
2728
2777
  cache.paddingX === signature.paddingX &&
@@ -2734,18 +2783,23 @@ export class Markdown implements Component {
2734
2783
  cache.hyperlinks === signature.hyperlinks &&
2735
2784
  cache.textSizing === signature.textSizing &&
2736
2785
  cache.bgColorProbe === signature.bgColorProbe &&
2737
- cache.headingProbe === signature.headingProbe
2786
+ cache.headingProbe === signature.headingProbe &&
2787
+ tokenText.startsWith(cache.text)
2738
2788
  ) {
2739
- if (completedText.length === cache.text.length) return cache.lines;
2789
+ const cachedEnd = cache.text.length;
2790
+ if (cachedEnd === completedEnd) return cache.lines;
2740
2791
  // Invariant: the stream has consumed `cache.text + "\n"`, so pushing
2741
2792
  // the added lines with a trailing newline advances it to
2742
- // `completedText + "\n"` — every fed line stays newline-terminated.
2743
- const addedText = completedText.slice(cache.text.length + 1);
2744
- const lines = cache.lines.concat(splitPushedHighlightLines(cache.stream.push(`${addedText}\n`)));
2745
- this.#streamingHighlightCache = { ...signature, lang, text: completedText, lines, stream: cache.stream };
2746
- return lines;
2793
+ // `completed + "\n"` — every fed line stays newline-terminated. The
2794
+ // signature gates above matched, so advancing the cache in place is
2795
+ // equivalent to rebuilding it, minus the whole-array copy per frame.
2796
+ const pushed = cache.stream.push(`${tokenText.slice(cachedEnd + 1, completedEnd)}\n`);
2797
+ appendLines(cache.lines, splitPushedHighlightLines(pushed));
2798
+ cache.text = tokenText.slice(0, completedEnd);
2799
+ return cache.lines;
2747
2800
  }
2748
2801
 
2802
+ const completedText = tokenText.slice(0, completedEnd);
2749
2803
  const stream = this.#createHighlightStream(lang);
2750
2804
  if (!stream) return null;
2751
2805
  const lines = splitPushedHighlightLines(stream.push(`${completedText}\n`));
@@ -64,6 +64,8 @@ export class LoopWatchdog {
64
64
  #expected = 0;
65
65
  #expectedCpu = 0;
66
66
  #wasBlocked = false;
67
+ // When the most recent late tick ran — the moment a detected block ended.
68
+ #stallEndedAt = Number.NEGATIVE_INFINITY;
67
69
  #running = false;
68
70
  // Bumped by stop(); each scheduled tick captures the generation it was armed
69
71
  // under and no-ops if it no longer matches, so a start()→stop()→start() cycle
@@ -94,17 +96,40 @@ export class LoopWatchdog {
94
96
  if (this.#running) return;
95
97
  this.#running = true;
96
98
  this.#wasBlocked = false;
99
+ this.#stallEndedAt = Number.NEGATIVE_INFINITY;
97
100
  this.#armTick();
98
101
  }
99
102
 
100
103
  stop(): void {
101
104
  this.#running = false;
102
105
  this.#wasBlocked = false;
106
+ this.#stallEndedAt = Number.NEGATIVE_INFINITY;
103
107
  this.#generation++;
104
108
  this.#handle?.cancel?.();
105
109
  this.#handle = undefined;
106
110
  }
107
111
 
112
+ /**
113
+ * Whether the loop is blocked right now — the armed tick is already more
114
+ * than `thresholdMs` overdue — or a detected block ended within the last
115
+ * `thresholdMs`. Input read at such a moment may be keystrokes the block
116
+ * batched into one read. Resolves both orders in which a resumed loop can
117
+ * run the late tick and the queued stdin read. A suspended-process gap is
118
+ * not a stall, and a stopped watchdog never reports one.
119
+ */
120
+ isStalled(): boolean {
121
+ if (!this.#running) return false;
122
+ const now = this.#now();
123
+ if (now - this.#stallEndedAt <= this.#thresholdMs) return true;
124
+ const overdueMs = now - this.#expected;
125
+ return overdueMs > this.#thresholdMs && !this.#isSuspension(overdueMs, this.#cpuNow() - this.#expectedCpu);
126
+ }
127
+
128
+ /** A long gap the process spent negligible CPU on: it was suspended, not blocked. */
129
+ #isSuspension(blockedMs: number, cpuMs: number): boolean {
130
+ return blockedMs > this.#sleepMs && cpuMs < blockedMs * CPU_BUSY_RATIO;
131
+ }
132
+
108
133
  #armTick(): void {
109
134
  const generation = this.#generation;
110
135
  this.#expected = this.#now() + this.#intervalMs;
@@ -115,23 +140,26 @@ export class LoopWatchdog {
115
140
 
116
141
  #tick(generation: number): void {
117
142
  if (!this.#running || generation !== this.#generation) return;
118
- const blockedMs = this.#now() - this.#expected;
143
+ const now = this.#now();
144
+ const blockedMs = now - this.#expected;
119
145
  const cpuMs = this.#cpuNow() - this.#expectedCpu;
120
146
  // Consume the recent phase every tick (block or not) so attribution is
121
147
  // scoped to the just-elapsed interval and never carries a stale phase
122
148
  // forward to a later, phase-less block.
123
149
  const phase = takeRecentLoopPhase();
124
150
  if (blockedMs > this.#thresholdMs) {
125
- if (blockedMs > this.#sleepMs && cpuMs < blockedMs * CPU_BUSY_RATIO) {
126
- // A long gap the process did not spend CPU on: it was suspended.
151
+ if (this.#isSuspension(blockedMs, cpuMs)) {
127
152
  this.#wasBlocked = false;
128
- } else if (!this.#wasBlocked) {
129
- this.#wasBlocked = true;
130
- logger.warn("ui.loop-blocked", {
131
- blockedMs: Math.round(blockedMs),
132
- cpuMs: Math.round(cpuMs),
133
- phase: phase ?? "unknown",
134
- });
153
+ } else {
154
+ this.#stallEndedAt = now;
155
+ if (!this.#wasBlocked) {
156
+ this.#wasBlocked = true;
157
+ logger.warn("ui.loop-blocked", {
158
+ blockedMs: Math.round(blockedMs),
159
+ cpuMs: Math.round(cpuMs),
160
+ phase: phase ?? "unknown",
161
+ });
162
+ }
135
163
  }
136
164
  } else {
137
165
  this.#wasBlocked = false;
@@ -28,7 +28,8 @@ export interface ResolvedRoleModel {
28
28
 
29
29
  /** Catalog refresh capability used by the session picker. */
30
30
  export interface ModelPickerRegistry extends ModelBrowserRegistry {
31
- refresh(strategy: "offline"): Promise<void>;
31
+ /** Bring the catalog up to date without rebuilding a current one; `true` when the picker must re-read it. */
32
+ refreshIfStale(): Promise<boolean>;
32
33
  }
33
34
 
34
35
  export interface ModelPickerCallbacks {
@@ -172,13 +173,15 @@ export class ModelPickerComponent implements Component {
172
173
  this.#browser.selectSelector(options.currentSelector);
173
174
  }
174
175
 
175
- // Reconcile with cached discovery state in the background. A --models
176
- // scope is registry-independent, so the offline reload would only repeat
177
- // the synchronous hydration above.
176
+ // Re-read only if the catalog moves (startup discovery landing, a
177
+ // models.yml edit): rebuilding a current catalog on every open blocks
178
+ // the first paint for seconds. A --models scope is registry-independent.
178
179
  if (this.#scopedModels.length === 0) {
179
180
  this.#registry
180
- .refresh("offline")
181
- .then(() => this.#syncFromRegistryState())
181
+ .refreshIfStale()
182
+ .then(changed => {
183
+ if (changed) this.#syncFromRegistryState();
184
+ })
182
185
  .catch(error => {
183
186
  this.#configError = error instanceof Error ? error.message : String(error);
184
187
  })
@@ -1139,15 +1139,7 @@ export class SettingsSelectorComponent implements Component {
1139
1139
  return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
1140
1140
  }
1141
1141
 
1142
- #getMultiSelectOptions(def: SettingDef & { type: "multiselect" }) {
1143
- if (def.path !== "providers.webSearchOrder") return def.options;
1144
- const excluded: unknown = this.#context.settings.get("providers.webSearchExclude");
1145
- if (!Array.isArray(excluded)) return def.options;
1146
- return def.options.filter(option => !excluded.includes(option.value));
1147
- }
1148
-
1149
1142
  #createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
1150
- const options = this.#getMultiSelectOptions(def);
1151
1143
  const current: unknown = this.#context.settings.get(def.path);
1152
1144
  const initial = Array.isArray(current)
1153
1145
  ? current.filter((entry): entry is string => typeof entry === "string")
@@ -1155,7 +1147,7 @@ export class SettingsSelectorComponent implements Component {
1155
1147
  return new MultiSelectSubmenu(
1156
1148
  def.label,
1157
1149
  def.description,
1158
- options,
1150
+ def.options,
1159
1151
  initial,
1160
1152
  def.ordered,
1161
1153
  value => {
@@ -1167,7 +1159,7 @@ export class SettingsSelectorComponent implements Component {
1167
1159
  }
1168
1160
 
1169
1161
  #formatMultiSelectValue(def: SettingDef & { type: "multiselect" }, value: unknown): string {
1170
- const options = this.#getMultiSelectOptions(def);
1162
+ const { options } = def;
1171
1163
  const labels = Array.isArray(value)
1172
1164
  ? value.flatMap(entry => {
1173
1165
  if (typeof entry !== "string") return [];
@@ -14,7 +14,7 @@ import { wrapTextWithAnsi } from "../../utils";
14
14
  import { getAgentDbPath } from "@oh-my-pi/pi-utils";
15
15
  import { OAuthSelectorComponent } from "../../overlays/oauth-selector";
16
16
  import { theme } from "../../theme/theme";
17
- import type { SetupSceneHost, SetupTab } from "./types";
17
+ import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
18
18
 
19
19
  function loginUrlLink(url: string): string {
20
20
  return `\x1b]8;;${url}\x07Open login URL\x1b]8;;\x07`;
@@ -69,13 +69,15 @@ interface PromptState {
69
69
  }
70
70
 
71
71
  /**
72
- * "Sign in" panel: lets the user authenticate one or more model providers via
73
- * OAuth. Unlike a standalone scene it never auto-advances the wizard — the user
74
- * may sign in to several providers and then continue with Esc.
72
+ * "Sign in" scene: lets the user authenticate one or more model providers via
73
+ * OAuth. It never auto-advances the wizard — the user may sign in to several
74
+ * providers and then continue with Esc.
75
75
  */
76
- export class SignInTab implements SetupTab {
77
- readonly id = "sign-in";
78
- readonly label = "Sign in";
76
+ export class SignInScene implements SetupSceneController {
77
+ readonly title = "Sign in to your providers";
78
+ get subtitle(): string {
79
+ return `Sign in to one or more providers. Press ${editorKey("tui.select.cancel")} when you're done.`;
80
+ }
79
81
 
80
82
  #authStorage: AuthStorage;
81
83
  #selector: OAuthSelectorComponent;
@@ -99,11 +101,6 @@ export class SignInTab implements SetupTab {
99
101
  this.#selector = this.#createSelector();
100
102
  }
101
103
 
102
- /** Modal while an OAuth flow is running so the scene won't switch tabs or finish. */
103
- get modal(): boolean {
104
- return this.#loggingInProvider !== undefined;
105
- }
106
-
107
104
  dispose(): void {
108
105
  this.#disposed = true;
109
106
  this.#selector.stopValidation();
@@ -373,3 +370,11 @@ export class SignInTab implements SetupTab {
373
370
  this.#host.requestRender();
374
371
  }
375
372
  }
373
+
374
+ /** Onboarding scene for provider sign-in. */
375
+ export const providersSetupScene: SetupScene = {
376
+ id: "providers",
377
+ title: "Sign in to your providers",
378
+ minVersion: 1,
379
+ mount: host => new SignInScene(host),
380
+ };
@@ -5,7 +5,6 @@ import type { ComposerPreviewStatusSource } from "../../overlays/composer-shape-
5
5
  import type { ComposerShape } from "../../overlays/composer-shape-registry";
6
6
  import type { ModelBrowserSource } from "../../overlays/model-browser";
7
7
  import type { SymbolPreset } from "../../theme/theme";
8
- import type { SearchProviderId } from "../../tools/web-search";
9
8
  import type { Component, TUI } from "../../tui";
10
9
 
11
10
  /** Terminal capabilities used by setup overlays and the startup splash. */
@@ -21,7 +20,6 @@ export interface SetupHost extends SetupUiHost {
21
20
  readonly composerShape: ComposerShape;
22
21
  readonly symbolPreset: SymbolPreset;
23
22
  readonly colorBlindMode: boolean;
24
- readonly webSearchOrder: readonly string[];
25
23
  readonly disabledProviders: readonly string[];
26
24
  readonly authStorage: AuthStorage;
27
25
  readonly modelSource: ModelBrowserSource;
@@ -33,8 +31,6 @@ export interface SetupHost extends SetupUiHost {
33
31
  saveSymbolPreset(preset: SymbolPreset): void;
34
32
  saveColorBlindMode(enabled: boolean): void;
35
33
  saveTheme(mode: "dark" | "light", name: string): void;
36
- isSearchProviderAvailable(id: SearchProviderId): Promise<boolean>;
37
- saveSearchProvider(id: SearchProviderId | "auto"): void;
38
34
  captureBrowserSession(request: OAuthBrowserSessionRequest, signal?: AbortSignal): Promise<string>;
39
35
  copyToClipboard(text: string): Promise<void>;
40
36
  openInBrowser(url: string): void;
@@ -78,29 +74,6 @@ export interface SetupSceneController extends Component {
78
74
  routeMouse?(event: SgrMouseEvent, line: number, col: number): void;
79
75
  }
80
76
 
81
- /**
82
- * A single panel inside a tabbed setup scene. The host scene owns the tab bar
83
- * and forwards rendering/input to the active tab.
84
- */
85
- export interface SetupTab {
86
- readonly id: string;
87
- readonly label: string;
88
- /**
89
- * While `true` the tab owns all keyboard input (e.g. an in-progress OAuth
90
- * login). The parent scene MUST NOT switch tabs or finish while modal.
91
- */
92
- readonly modal: boolean;
93
- /** See {@link SetupSceneController.render}: `maxLines` is the tab-local row budget. */
94
- render(width: number, maxLines?: number): readonly string[];
95
- handleInput(data: string): void;
96
- invalidate(): void;
97
- /** Called when the tab becomes active (including initial mount). */
98
- onActivate?(): void;
99
- /** Mouse routing at tab-local coordinates; see {@link SetupSceneController.routeMouse}. */
100
- routeMouse?(event: SgrMouseEvent, line: number, col: number): void;
101
- dispose(): void;
102
- }
103
-
104
77
  /** Versioned onboarding scene definition. */
105
78
  export interface SetupScene {
106
79
  id: string;
@@ -3,7 +3,7 @@ import type { SetupHost } from "./scenes/types";
3
3
  import { composerSetupScene } from "./scenes/composer";
4
4
  import { glyphSetupScene } from "./scenes/glyph";
5
5
  import { modelSetupScene } from "./scenes/model";
6
- import { providersSetupScene } from "./scenes/providers";
6
+ import { providersSetupScene } from "./scenes/sign-in";
7
7
  import { themeSetupScene } from "./scenes/theme";
8
8
  import type { SetupScene } from "./scenes/types";
9
9
  import { SetupWizardComponent } from "./wizard-overlay";