@oh-my-pi/pi-tui 18.7.0 → 18.8.0

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 (79) hide show
  1. package/CHANGELOG.md +21 -52
  2. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  3. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  4. package/dist/types/chat/fence-figure.d.ts +42 -0
  5. package/dist/types/chat/svg-figure.d.ts +1 -1
  6. package/dist/types/chat/tool-execution.d.ts +2 -0
  7. package/dist/types/components/editor.d.ts +8 -3
  8. package/dist/types/components/markdown.d.ts +11 -6
  9. package/dist/types/fuzzy.d.ts +17 -3
  10. package/dist/types/native/backend.d.ts +2 -1
  11. package/dist/types/native/blobs.d.ts +4 -2
  12. package/dist/types/native/encode.d.ts +3 -4
  13. package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
  14. package/dist/types/overlays/history-search.d.ts +10 -0
  15. package/dist/types/overlays/model-browser.d.ts +6 -0
  16. package/dist/types/prompt/custom-editor.d.ts +7 -4
  17. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
  18. package/dist/types/tools/renderer.d.ts +22 -0
  19. package/dist/types/tools/streaming-output.d.ts +11 -1
  20. package/dist/types/tools/write.d.ts +8 -1
  21. package/package.json +9 -9
  22. package/src/apps/debug/log-viewer.ts +188 -72
  23. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  24. package/src/apps/debug/raw-sse.ts +39 -5
  25. package/src/apps/git/diff-pane.ts +26 -5
  26. package/src/autocomplete.ts +59 -33
  27. package/src/chat/assistant-message.ts +30 -18
  28. package/src/chat/fence-figure.ts +80 -0
  29. package/src/chat/svg-figure.ts +2 -4
  30. package/src/chat/tool-execution.ts +53 -4
  31. package/src/components/editor.ts +16 -3
  32. package/src/components/markdown.ts +80 -307
  33. package/src/fuzzy.ts +59 -14
  34. package/src/native/backend.ts +170 -15
  35. package/src/native/blobs.ts +4 -2
  36. package/src/native/encode.ts +58 -31
  37. package/src/overlays/agent-hub-projection.ts +70 -2
  38. package/src/overlays/agent-hub.ts +3 -2
  39. package/src/overlays/agent-transcript-viewer.ts +64 -13
  40. package/src/overlays/agents-hub.ts +14 -7
  41. package/src/overlays/copy-selector.ts +30 -8
  42. package/src/overlays/extensions/state-manager.ts +52 -12
  43. package/src/overlays/history-search.ts +100 -7
  44. package/src/overlays/jobs-panel.ts +30 -2
  45. package/src/overlays/model-browser.ts +107 -21
  46. package/src/overlays/model-hub.ts +34 -7
  47. package/src/overlays/oauth-selector.ts +41 -26
  48. package/src/overlays/plan-review-overlay.ts +142 -64
  49. package/src/overlays/rewind-selector.ts +86 -17
  50. package/src/overlays/session-selector.ts +24 -4
  51. package/src/overlays/settings-selector.ts +21 -6
  52. package/src/overlays/tree-selector.ts +20 -4
  53. package/src/overlays/usage-dashboard.ts +12 -15
  54. package/src/prompt/attachment-chips.ts +2 -1
  55. package/src/prompt/composer-attachments.ts +6 -4
  56. package/src/prompt/custom-editor.ts +48 -19
  57. package/src/prompt/interactive-context-helpers.ts +27 -18
  58. package/src/prompt/macos-spelling.ts +7 -0
  59. package/src/prompt/magic-keywords.ts +11 -2
  60. package/src/prompt/word-completion.ts +38 -5
  61. package/src/setup/scenes/splash.ts +109 -45
  62. package/src/status-line/component.ts +220 -64
  63. package/src/status-line/segments.ts +5 -2
  64. package/src/terminal.ts +3 -2
  65. package/src/tools/ast-edit.ts +59 -35
  66. package/src/tools/ast-grep.ts +35 -16
  67. package/src/tools/default-renderer.ts +35 -19
  68. package/src/tools/eval-format/index.ts +28 -2
  69. package/src/tools/eval-format/javascript.ts +7 -5
  70. package/src/tools/eval.ts +164 -78
  71. package/src/tools/grep.ts +81 -48
  72. package/src/tools/lsp.ts +81 -59
  73. package/src/tools/mcp.ts +98 -68
  74. package/src/tools/renderer.ts +27 -0
  75. package/src/tools/streaming-output.ts +120 -48
  76. package/src/tools/task.ts +111 -54
  77. package/src/tools/todo.ts +115 -29
  78. package/src/tools/wait.ts +34 -24
  79. package/src/tools/write.ts +77 -39
@@ -27,7 +27,7 @@ import {
27
27
  parseConfiguredThinkingLevel,
28
28
  } from "../thinking";
29
29
  import { thinkingLevelGlyph } from "../render/render-utils";
30
- import { type ThemeColor, theme } from "../theme/theme";
30
+ import { type Theme, type ThemeColor, theme } from "../theme/theme";
31
31
  import {
32
32
  matchesSelectCancel,
33
33
  matchesSelectDown,
@@ -887,6 +887,24 @@ function metaColumnsWidth(widths: readonly number[]): number {
887
887
  /** What the per-row perf column shows at the current width. */
888
888
  type PerfMode = "off" | "tps" | "full";
889
889
 
890
+ /** A measured perf aggregate and, when it came from one, its service tier. */
891
+ interface MeasuredPerf {
892
+ perf: ModelBrowserPerf;
893
+ tier?: string;
894
+ }
895
+
896
+ /** Plain catalog metric cells of one row and their terminal widths, valid for one model and theme. */
897
+ interface MetricCells {
898
+ model: Model;
899
+ theme: Theme;
900
+ intelligence: string;
901
+ intelligenceWidth: number;
902
+ ctx: string;
903
+ ctxWidth: number;
904
+ cost: string;
905
+ costWidth: number;
906
+ }
907
+
890
908
  /**
891
909
  * The reusable browser component. Renders a fixed-height block
892
910
  * (`maxVisible + LIST_ROW_START + DETAIL_ROWS` rows) so host mouse geometry
@@ -907,6 +925,14 @@ export class ModelBrowser implements Component {
907
925
  /** Ranker for the menu's current item array; rebuilt when items or affinity change. */
908
926
  #ranker?: { source: readonly ModelBrowserItem[]; ranker: ModelItemRanker };
909
927
  #perf: ReadonlyMap<string, ModelBrowserPerf> = new Map();
928
+ /** Fastest tier aggregate per selector in {@link #perf}, built on the first lookup miss. */
929
+ #perfTiers:
930
+ | { perf: ReadonlyMap<string, ModelBrowserPerf>; size: number; best: Map<string, MeasuredPerf> }
931
+ | undefined;
932
+ /** Theme-formatted catalog metric cells per item; rebuilt when the item's model or the theme changes. */
933
+ #metricCells = new WeakMap<ModelBrowserItem, MetricCells>();
934
+ /** Non-separator items of the last ranked visible list, for {@link queryMatches}. */
935
+ #queryMatches: { visible: readonly ModelBrowserItem[]; matches: readonly ModelBrowserItem[] } | undefined;
910
936
  #hoveredIndex: number | null = null;
911
937
  #maxVisible = 10;
912
938
  #showProvider: boolean;
@@ -1075,6 +1101,22 @@ export class ModelBrowser implements Component {
1075
1101
  return this.#menu.visibleItems.length;
1076
1102
  }
1077
1103
 
1104
+ /**
1105
+ * Base items matching the live query, best first, without the separator
1106
+ * row; undefined while the query is blank. Reuses the ranking the list
1107
+ * already ran, so hosts can derive match counts without ranking again.
1108
+ */
1109
+ get queryMatches(): readonly ModelBrowserItem[] | undefined {
1110
+ if (!this.query.trim()) return undefined;
1111
+ const visible = this.#menu.visibleItems;
1112
+ let cached = this.#queryMatches;
1113
+ if (cached?.visible !== visible) {
1114
+ cached = { visible, matches: visible.filter(item => !this.#isDisabled(item)) };
1115
+ this.#queryMatches = cached;
1116
+ }
1117
+ return cached.matches;
1118
+ }
1119
+
1078
1120
  /** Move selection to `selector`; false when it is not in the current view. */
1079
1121
  selectSelector(selector: string): boolean {
1080
1122
  if (!this.#menu.setSelectedKey(selector)) return false;
@@ -1339,7 +1381,8 @@ export class ModelBrowser implements Component {
1339
1381
  * on a non-default tier still shows its real speed. `tier` is set whenever
1340
1382
  * the numbers come from a tier aggregate, for the caller to label.
1341
1383
  */
1342
- #perfFor(item: ModelBrowserItem): { perf: ModelBrowserPerf; tier?: string } | undefined {
1384
+ #perfFor(item: ModelBrowserItem): MeasuredPerf | undefined {
1385
+ if (this.#perf.size === 0) return undefined;
1343
1386
  const tier = this.#settings.serviceTierFor?.(item.model);
1344
1387
  if (tier) {
1345
1388
  const tiered = this.#perf.get(`${item.selector}@${tier}`);
@@ -1347,13 +1390,48 @@ export class ModelBrowser implements Component {
1347
1390
  }
1348
1391
  const standard = this.#perf.get(item.selector);
1349
1392
  if (standard) return { perf: standard };
1350
- const prefix = `${item.selector}@`;
1351
- let best: { perf: ModelBrowserPerf; tier: string } | undefined;
1352
- for (const [key, perf] of this.#perf) {
1353
- if (!key.startsWith(prefix)) continue;
1354
- if (!best || perf.tps > best.perf.tps) best = { perf, tier: key.slice(prefix.length) };
1393
+ return this.#fastestTierPerf(item.selector);
1394
+ }
1395
+
1396
+ /** Fastest `selector@tier` aggregate (first wins ties), from an index built once per perf map. */
1397
+ #fastestTierPerf(selector: string): MeasuredPerf | undefined {
1398
+ const perfMap = this.#perf;
1399
+ let index = this.#perfTiers;
1400
+ if (index?.perf !== perfMap || index.size !== perfMap.size) {
1401
+ const best = new Map<string, MeasuredPerf>();
1402
+ for (const [key, perf] of perfMap) {
1403
+ // Every `@` may end the selector: `a@b@t` is tier `b@t` of `a` and tier `t` of `a@b`.
1404
+ for (let at = key.indexOf("@"); at >= 0; at = key.indexOf("@", at + 1)) {
1405
+ const owner = key.slice(0, at);
1406
+ const current = best.get(owner);
1407
+ if (!current || perf.tps > current.perf.tps) best.set(owner, { perf, tier: key.slice(at + 1) });
1408
+ }
1409
+ }
1410
+ index = { perf: perfMap, size: perfMap.size, best };
1411
+ this.#perfTiers = index;
1355
1412
  }
1356
- return best;
1413
+ return index.best.get(selector);
1414
+ }
1415
+
1416
+ /** Catalog metric cells of a row, formatted once per model and theme. */
1417
+ #metricCellsFor(item: ModelBrowserItem): MetricCells {
1418
+ const cached = this.#metricCells.get(item);
1419
+ if (cached?.model === item.model && cached.theme === theme) return cached;
1420
+ const intelligence = formatIntelligence(item.model);
1421
+ const ctx = formatContext(item.model);
1422
+ const cost = formatCostPair(item.model);
1423
+ const cells: MetricCells = {
1424
+ model: item.model,
1425
+ theme,
1426
+ intelligence,
1427
+ intelligenceWidth: visibleWidth(intelligence),
1428
+ ctx,
1429
+ ctxWidth: visibleWidth(ctx),
1430
+ cost,
1431
+ costWidth: visibleWidth(cost),
1432
+ };
1433
+ this.#metricCells.set(item, cells);
1434
+ return cells;
1357
1435
  }
1358
1436
 
1359
1437
  /** Measured TPS/TTFT, falling back to the catalog TPS as an estimated `~118t/s`. */
@@ -1381,7 +1459,8 @@ export class ModelBrowser implements Component {
1381
1459
  costWidth: number,
1382
1460
  intelligenceWidth: number,
1383
1461
  perfWidth: number,
1384
- perfMode: PerfMode,
1462
+ metrics: MetricCells,
1463
+ perfCell: string,
1385
1464
  ): string {
1386
1465
  if (item.id === "separator") {
1387
1466
  const dashCount = Math.max(0, width - 4);
@@ -1405,11 +1484,10 @@ export class ModelBrowser implements Component {
1405
1484
 
1406
1485
  // Metric columns collapse when empty or when the row needs room for its name.
1407
1486
  const cols: string[] = [];
1408
- if (intelligenceWidth > 0)
1409
- cols.push(theme.fg("dim", padLeftVisible(formatIntelligence(item.model), intelligenceWidth)));
1410
- if (perfWidth > 0) cols.push(theme.fg("dim", padLeftVisible(this.#perfCell(item, perfMode), perfWidth)));
1411
- if (ctxWidth > 0) cols.push(theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth)));
1412
- if (costWidth > 0) cols.push(theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth)));
1487
+ if (intelligenceWidth > 0) cols.push(theme.fg("dim", padLeftVisible(metrics.intelligence, intelligenceWidth)));
1488
+ if (perfWidth > 0) cols.push(theme.fg("dim", padLeftVisible(perfCell, perfWidth)));
1489
+ if (ctxWidth > 0) cols.push(theme.fg("dim", padLeftVisible(metrics.ctx, ctxWidth)));
1490
+ if (costWidth > 0) cols.push(theme.fg("dim", padLeftVisible(metrics.cost, costWidth)));
1413
1491
  const metaWidth = metaColumnsWidth([intelligenceWidth, perfWidth, ctxWidth, costWidth]);
1414
1492
  const available = Math.max(1, width - metaWidth - (cols.length > 0 ? 1 : 0));
1415
1493
  left = truncateToWidth(left, available);
@@ -1515,15 +1593,22 @@ export class ModelBrowser implements Component {
1515
1593
  const perfMode: PerfMode = width >= PERF_FULL_MIN_WIDTH ? "full" : width >= PERF_TPS_MIN_WIDTH ? "tps" : "off";
1516
1594
  let intelligenceWidth = 0;
1517
1595
  let perfWidth = 0;
1596
+ const visible = this.#menu.visibleItems;
1597
+ const windowMetrics: MetricCells[] = [];
1598
+ const windowPerf: string[] = [];
1518
1599
  for (let i = startIndex; i < endIndex; i++) {
1519
- const item = this.#menu.visibleItems[i];
1600
+ const item = visible[i];
1520
1601
  if (!item) continue;
1521
- ctxWidth = Math.max(ctxWidth, visibleWidth(formatContext(item.model)));
1522
- costWidth = Math.max(costWidth, visibleWidth(formatCostPair(item.model)));
1602
+ const metrics = this.#metricCellsFor(item);
1603
+ const perfCell = this.#perfCell(item, perfMode);
1604
+ windowMetrics[i - startIndex] = metrics;
1605
+ windowPerf[i - startIndex] = perfCell;
1606
+ ctxWidth = Math.max(ctxWidth, metrics.ctxWidth);
1607
+ costWidth = Math.max(costWidth, metrics.costWidth);
1523
1608
  if (perfMode !== "off") {
1524
- intelligenceWidth = Math.max(intelligenceWidth, visibleWidth(formatIntelligence(item.model)));
1609
+ intelligenceWidth = Math.max(intelligenceWidth, metrics.intelligenceWidth);
1525
1610
  }
1526
- perfWidth = Math.max(perfWidth, visibleWidth(this.#perfCell(item, perfMode)));
1611
+ perfWidth = Math.max(perfWidth, visibleWidth(perfCell));
1527
1612
  }
1528
1613
  // Preserve at least a readable name by dropping cost, then context.
1529
1614
  let nameRoom = width - 2 - metaColumnsWidth([intelligenceWidth, perfWidth, ctxWidth, costWidth]);
@@ -1533,7 +1618,7 @@ export class ModelBrowser implements Component {
1533
1618
 
1534
1619
  const rows: string[] = [];
1535
1620
  for (let i = startIndex; i < endIndex; i++) {
1536
- const item = this.#menu.visibleItems[i];
1621
+ const item = visible[i];
1537
1622
  if (!item) continue;
1538
1623
  rows.push(
1539
1624
  this.#renderRow(
@@ -1545,7 +1630,8 @@ export class ModelBrowser implements Component {
1545
1630
  costWidth,
1546
1631
  intelligenceWidth,
1547
1632
  perfWidth,
1548
- perfMode,
1633
+ windowMetrics[i - startIndex]!,
1634
+ windowPerf[i - startIndex]!,
1549
1635
  ),
1550
1636
  );
1551
1637
  }
@@ -28,7 +28,7 @@ import { providerEntry } from "@oh-my-pi/pi-catalog/compat/providers";
28
28
  import { MODEL_KINDS, modelKind, type ModelKind } from "@oh-my-pi/pi-catalog/types";
29
29
  import type { Component, TUI } from "../tui";
30
30
  import { extractPrintableText, matchesKey } from "../keys";
31
- import { fuzzyFilter } from "../fuzzy";
31
+ import { FuzzyCorpus } from "../fuzzy";
32
32
  import { formatKeyHint, formatKeyHints } from "../app-keybindings";
33
33
  import { boundKeys, editorKey, editorKeys } from "../chrome/keybinding-hints";
34
34
  import type { KeyName } from "../key-hint-format";
@@ -334,7 +334,15 @@ export class ModelHubComponent implements Component {
334
334
  #roles: RoleAssignments = {};
335
335
  #availableItems: ModelBrowserItem[] = [];
336
336
  #recentItems: ModelBrowserItem[] = [];
337
+ /** Selectors of {@link #recentItems}, rebuilt with it, for search hit counts. */
338
+ #recentSelectors: ReadonlySet<string> = new Set();
337
339
  #candidateItems: ModelBrowserItem[] = [];
340
+ /** {@link #availableItems} when the candidates are that whole catalog (All scope, no role filter). */
341
+ #candidateCatalog: readonly ModelBrowserItem[] | undefined;
342
+ /** {@link #availableItems} when the browser's base items are that whole catalog; its query ranking then covers every count. */
343
+ #browserCatalog: readonly ModelBrowserItem[] | undefined;
344
+ /** Fuzzy index over {@link #availableItems}, for match counts while the browser holds a narrower scope. */
345
+ #catalogCorpus: { items: readonly ModelBrowserItem[]; corpus: FuzzyCorpus<ModelBrowserItem> } | undefined;
338
346
  #modelKindTab: "all" | ModelKind = "all";
339
347
  #roleTab: RoleTab = "all";
340
348
  #configError: string | undefined;
@@ -587,6 +595,7 @@ export class ModelHubComponent implements Component {
587
595
  if (item) this.#recentItems.push(item);
588
596
  if (this.#recentItems.length >= RECENT_LIMIT) break;
589
597
  }
598
+ this.#recentSelectors = new Set(this.#recentItems.map(item => item.selector));
590
599
 
591
600
  this.#buildSidebar(allModels, availableModels);
592
601
  this.#restoreSidebarAnchor(anchor);
@@ -823,11 +832,13 @@ export class ModelHubComponent implements Component {
823
832
  ? items.filter(item => this.#settings.getRoleInfo(assigning.role).accepts(item.model))
824
833
  : items;
825
834
  this.#candidateItems = [...scoped];
835
+ this.#candidateCatalog = scoped === this.#availableItems ? this.#availableItems : undefined;
826
836
  this.#applyModelKind();
827
837
  }
828
838
 
829
839
  #applyModelKind(): void {
830
840
  const kind = this.#modelKindTab;
841
+ this.#browserCatalog = kind === "all" ? this.#candidateCatalog : undefined;
831
842
  this.#browser.setItems(
832
843
  kind === "all"
833
844
  ? [...this.#candidateItems]
@@ -936,16 +947,14 @@ export class ModelHubComponent implements Component {
936
947
  this.#composeEntries();
937
948
  return;
938
949
  }
939
- const matches = fuzzyFilter(this.#availableItems, query, modelSearchText);
950
+ const matches = this.#catalogMatches(query);
940
951
  const counts = new Map<string, number>();
952
+ let recentCount = 0;
941
953
  for (const item of matches) {
942
954
  counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1);
955
+ if (this.#recentSelectors.has(item.selector)) recentCount++;
943
956
  }
944
- const recentSelectors = new Set(this.#recentItems.map(item => item.selector));
945
- this.#recentSearchCount = matches.reduce(
946
- (total, item) => total + (recentSelectors.has(item.selector) ? 1 : 0),
947
- 0,
948
- );
957
+ this.#recentSearchCount = recentCount;
949
958
  this.#searchTotal = matches.length;
950
959
  this.#searchCounts = counts;
951
960
  this.#composeEntries();
@@ -959,6 +968,24 @@ export class ModelHubComponent implements Component {
959
968
  }
960
969
  }
961
970
 
971
+ /**
972
+ * Catalog items matching a non-blank `query`. When the browser holds the
973
+ * whole catalog its ranking for this query already ran, so reuse it;
974
+ * otherwise scan a catalog index kept across keystrokes.
975
+ */
976
+ #catalogMatches(query: string): readonly ModelBrowserItem[] {
977
+ if (this.#browserCatalog === this.#availableItems && this.#browser.query === query) {
978
+ const ranked = this.#browser.queryMatches;
979
+ if (ranked) return ranked;
980
+ }
981
+ let cached = this.#catalogCorpus;
982
+ if (cached?.items !== this.#availableItems) {
983
+ cached = { items: this.#availableItems, corpus: new FuzzyCorpus(this.#availableItems, modelSearchText) };
984
+ this.#catalogCorpus = cached;
985
+ }
986
+ return cached.corpus.rank(query).map(result => result.item);
987
+ }
988
+
962
989
  /**
963
990
  * Entries the scope hop skips: separators always; while searching, also
964
991
  * the Roles view (not a model scope), an empty Recent, locked providers,
@@ -51,6 +51,8 @@ const ORIGIN_LABELS = {
51
51
  */
52
52
  export class OAuthSelectorComponent extends OverlayPanel {
53
53
  #listContainer: Container;
54
+ /** Provider rows viewport of the last {@link #updateList}; spinner ticks repaint only its rows. */
55
+ #listView: ScrollView | undefined;
54
56
  #menu: MenuSelection<OAuthProviderInfo>;
55
57
  /** The provider search field; its value drives `#menu`'s query. */
56
58
  #search = Object.assign(new Input(), { prompt: "" });
@@ -213,7 +215,13 @@ export class OAuthSelectorComponent extends OverlayPanel {
213
215
  if (frameCount > 0) {
214
216
  this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
215
217
  }
216
- this.#updateList();
218
+ // Only the provider rows carry the spinner glyph; the window is unchanged.
219
+ if (this.#listView) {
220
+ const start = this.#scrollStart;
221
+ this.#listView.setLines(this.#providerRows(start, start + this.#visibleCount));
222
+ } else {
223
+ this.#updateList();
224
+ }
217
225
  this.#requestRenderCallback?.();
218
226
  }, 80);
219
227
  }
@@ -310,37 +318,15 @@ export class OAuthSelectorComponent extends OverlayPanel {
310
318
  this.#nativeRoot = undefined;
311
319
  this.#pickerRoot = undefined;
312
320
  this.#listContainer.clear();
321
+ this.#listView = undefined;
313
322
 
314
- const items = this.#menu.visibleItems;
315
- const total = items.length;
323
+ const total = this.#menu.visibleItems.length;
316
324
  const maxVisible = this.#maxVisible;
317
325
  const { start: startIndex, end: endIndex } = centeredViewportRange(this.#menu.selectedIndex, total, maxVisible);
318
326
  this.#scrollStart = startIndex;
319
327
  this.#visibleCount = endIndex - startIndex;
320
328
 
321
- const rows: string[] = [];
322
- for (let i = startIndex; i < endIndex; i++) {
323
- const provider = items[i];
324
- if (!provider) continue;
325
- const isSelected = i === this.#menu.selectedIndex;
326
- const isAvailable = provider.available;
327
- const statusIndicator = this.#getStatusIndicator(provider.id);
328
-
329
- let line = "";
330
- if (isSelected) {
331
- const prefix = theme.fg("accent", `${theme.nav.cursor} `);
332
- const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name);
333
- line = prefix + text + statusIndicator;
334
- } else {
335
- const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
336
- line = text + statusIndicator;
337
- }
338
- if (!isSelected && i === this.#hoveredIndex) {
339
- line = theme.bg("selectedBg", line);
340
- }
341
- rows.push(line);
342
- }
343
-
329
+ const rows = this.#providerRows(startIndex, endIndex);
344
330
  if (rows.length > 0) {
345
331
  const sv = new ScrollView(rows, {
346
332
  height: rows.length,
@@ -349,6 +335,7 @@ export class OAuthSelectorComponent extends OverlayPanel {
349
335
  theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
350
336
  });
351
337
  sv.setScrollOffset(startIndex);
338
+ this.#listView = sv;
352
339
  this.#listContainer.addChild(sv);
353
340
  }
354
341
 
@@ -371,6 +358,34 @@ export class OAuthSelectorComponent extends OverlayPanel {
371
358
  this.#listContainer.addChild(new TruncatedText(theme.fg("warning", this.#statusMessage), 0, 0));
372
359
  }
373
360
  }
361
+
362
+ /** Styled rows for visible providers `[startIndex, endIndex)`: cursor, name, auth status and provenance. */
363
+ #providerRows(startIndex: number, endIndex: number): string[] {
364
+ const items = this.#menu.visibleItems;
365
+ const rows: string[] = [];
366
+ for (let i = startIndex; i < endIndex; i++) {
367
+ const provider = items[i];
368
+ if (!provider) continue;
369
+ const isSelected = i === this.#menu.selectedIndex;
370
+ const isAvailable = provider.available;
371
+ const statusIndicator = this.#getStatusIndicator(provider.id);
372
+
373
+ let line = "";
374
+ if (isSelected) {
375
+ const prefix = theme.fg("accent", `${theme.nav.cursor} `);
376
+ const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name);
377
+ line = prefix + text + statusIndicator;
378
+ } else {
379
+ const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
380
+ line = text + statusIndicator;
381
+ }
382
+ if (!isSelected && i === this.#hoveredIndex) {
383
+ line = theme.bg("selectedBg", line);
384
+ }
385
+ rows.push(line);
386
+ }
387
+ return rows;
388
+ }
374
389
  handleInput(keyData: string): void {
375
390
  // Escape or Ctrl+C
376
391
  if (matchesSelectCancel(keyData)) {