@oh-my-pi/pi-coding-agent 15.9.5 → 15.10.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 (192) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +10 -2
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -6,6 +6,7 @@ import {
6
6
  getKeybindings,
7
7
  Input,
8
8
  matchesKey,
9
+ ScrollView,
9
10
  Spacer,
10
11
  type Tab,
11
12
  TabBar,
@@ -13,6 +14,7 @@ import {
13
14
  type TUI,
14
15
  visibleWidth,
15
16
  } from "@oh-my-pi/pi-tui";
17
+ import { formatNumber } from "@oh-my-pi/pi-utils";
16
18
  import type { ModelRegistry } from "../../config/model-registry";
17
19
  import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry";
18
20
  import { resolveModelRoleValue } from "../../config/model-resolver";
@@ -147,6 +149,7 @@ export class ModelSelectorComponent extends Container {
147
149
  #tui: TUI;
148
150
  #scopedModels: ReadonlyArray<ScopedModelItem>;
149
151
  #temporaryOnly: boolean;
152
+ #currentContextTokens: number;
150
153
 
151
154
  #menuRoleActions: MenuRoleAction[] = [];
152
155
 
@@ -172,7 +175,7 @@ export class ModelSelectorComponent extends Container {
172
175
  scopedModels: ReadonlyArray<ScopedModelItem>,
173
176
  onSelect: RoleSelectCallback,
174
177
  onCancel: () => void,
175
- options?: { temporaryOnly?: boolean; initialSearchInput?: string },
178
+ options?: { temporaryOnly?: boolean; initialSearchInput?: string; currentContextTokens?: number },
176
179
  ) {
177
180
  super();
178
181
 
@@ -183,6 +186,9 @@ export class ModelSelectorComponent extends Container {
183
186
  this.#onSelectCallback = onSelect;
184
187
  this.#onCancelCallback = onCancel;
185
188
  this.#temporaryOnly = options?.temporaryOnly ?? false;
189
+ const currentContextTokens = options?.currentContextTokens ?? 0;
190
+ this.#currentContextTokens =
191
+ Number.isFinite(currentContextTokens) && currentContextTokens > 0 ? Math.floor(currentContextTokens) : 0;
186
192
  const initialSearchInput = options?.initialSearchInput;
187
193
 
188
194
  // Initialize menu role actions (built-in + custom from settings)
@@ -215,8 +221,8 @@ export class ModelSelectorComponent extends Container {
215
221
  this.#searchInput.setValue(initialSearchInput);
216
222
  }
217
223
  this.#searchInput.onSubmit = () => {
218
- // Enter on search input opens menu if we have a selection
219
- if (this.#filteredModels[this.#selectedIndex]) {
224
+ // Enter on search input opens menu if we have an enabled selection
225
+ if (this.#getSelectedItem()) {
220
226
  this.#openMenu();
221
227
  }
222
228
  };
@@ -460,7 +466,11 @@ export class ModelSelectorComponent extends Container {
460
466
  this.#filteredModels = models;
461
467
  this.#canonicalModels = canonicalModels;
462
468
  this.#filteredCanonicalModels = canonicalModels;
463
- this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1));
469
+ const visibleModels = this.#isCanonicalTab() ? canonicalModels : models;
470
+ this.#selectedIndex = this.#coerceSelectedIndex(
471
+ Math.min(this.#selectedIndex, Math.max(0, visibleModels.length - 1)),
472
+ visibleModels,
473
+ );
464
474
  }
465
475
 
466
476
  async #loadModels(): Promise<void> {
@@ -626,6 +636,74 @@ export class ModelSelectorComponent extends Container {
626
636
  return this.#getActiveTabId() === CANONICAL_TAB;
627
637
  }
628
638
 
639
+ #isModelOverContextLimit(model: Model): boolean {
640
+ const contextWindow = model.contextWindow ?? 0;
641
+ return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow;
642
+ }
643
+
644
+ #isItemDisabled(item: ModelItem | CanonicalModelItem): boolean {
645
+ return this.#isModelOverContextLimit(item.model);
646
+ }
647
+
648
+ #formatContextLimitSuffix(model: Model): string {
649
+ if (!this.#isModelOverContextLimit(model)) {
650
+ return "";
651
+ }
652
+ return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow).toLowerCase()}`;
653
+ }
654
+
655
+ #getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
656
+ return this.#isCanonicalTab() ? this.#filteredCanonicalModels : this.#filteredModels;
657
+ }
658
+
659
+ #coerceSelectedIndex(
660
+ index: number,
661
+ visibleItems: ReadonlyArray<ModelItem | CanonicalModelItem> = this.#getVisibleItems(),
662
+ ): number {
663
+ const maxIndex = visibleItems.length - 1;
664
+ if (maxIndex < 0) {
665
+ return 0;
666
+ }
667
+ const clamped = Math.max(0, Math.min(index, maxIndex));
668
+ const clampedItem = visibleItems[clamped];
669
+ if (clampedItem && !this.#isItemDisabled(clampedItem)) {
670
+ return clamped;
671
+ }
672
+ for (let i = clamped + 1; i <= maxIndex; i++) {
673
+ const item = visibleItems[i];
674
+ if (item && !this.#isItemDisabled(item)) {
675
+ return i;
676
+ }
677
+ }
678
+ for (let i = clamped - 1; i >= 0; i--) {
679
+ const item = visibleItems[i];
680
+ if (item && !this.#isItemDisabled(item)) {
681
+ return i;
682
+ }
683
+ }
684
+ return clamped;
685
+ }
686
+
687
+ #moveSelection(delta: number): void {
688
+ const visibleItems = this.#getVisibleItems();
689
+ const count = visibleItems.length;
690
+ if (count === 0) {
691
+ return;
692
+ }
693
+ let index = this.#selectedIndex;
694
+ for (let step = 0; step < count; step++) {
695
+ index = (index + delta + count) % count;
696
+ const item = visibleItems[index];
697
+ if (item && !this.#isItemDisabled(item)) {
698
+ this.#selectedIndex = index;
699
+ this.#updateList();
700
+ return;
701
+ }
702
+ }
703
+ this.#selectedIndex = this.#coerceSelectedIndex(this.#selectedIndex, visibleItems);
704
+ this.#updateList();
705
+ }
706
+
629
707
  #filterModels(query: string): void {
630
708
  const activeTabId = this.#getActiveTabId();
631
709
  const activeProviderId = this.#getActiveProviderId();
@@ -696,8 +774,11 @@ export class ModelSelectorComponent extends Container {
696
774
  this.#filteredCanonicalModels = baseCanonicalModels;
697
775
  }
698
776
 
699
- const visibleCount = isCanonicalTab ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
700
- this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, visibleCount - 1));
777
+ const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
778
+ this.#selectedIndex = this.#coerceSelectedIndex(
779
+ Math.min(this.#selectedIndex, Math.max(0, visibleItems.length - 1)),
780
+ visibleItems,
781
+ );
701
782
  this.#updateList();
702
783
  }
703
784
 
@@ -778,6 +859,7 @@ export class ModelSelectorComponent extends Container {
778
859
 
779
860
  const showProvider = this.#getActiveTabId() === ALL_TAB;
780
861
 
862
+ const rows: string[] = [];
781
863
  // Show visible slice of filtered models
782
864
  for (let i = startIndex; i < endIndex; i++) {
783
865
  const item = visibleItems[i];
@@ -786,6 +868,8 @@ export class ModelSelectorComponent extends Container {
786
868
  const providerItem = isCanonicalTab ? undefined : (item as ModelItem);
787
869
 
788
870
  const isSelected = i === this.#selectedIndex;
871
+ const isDisabled = this.#isItemDisabled(item);
872
+ const disabledSuffix = this.#formatContextLimitSuffix(item.model);
789
873
 
790
874
  // Build role badges (inverted: color as background, black text)
791
875
  const roleBadgeTokens: string[] = [];
@@ -815,34 +899,42 @@ export class ModelSelectorComponent extends Container {
815
899
  if (isCanonicalTab) {
816
900
  const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
817
901
  const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
818
- line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}`;
902
+ line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}${disabledSuffix}`;
819
903
  } else if (showProvider) {
820
904
  const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
821
- line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}`;
905
+ line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}${disabledSuffix}`;
822
906
  } else {
823
- line = `${prefix}${theme.fg("accent", item.id)}${badgeText}`;
907
+ line = `${prefix}${theme.fg("accent", item.id)}${badgeText}${disabledSuffix}`;
824
908
  }
825
909
  } else {
826
910
  const prefix = " ";
827
911
  if (isCanonicalTab) {
828
912
  const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
829
913
  const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
830
- line = `${prefix}${item.id}${variants}${backing}${badgeText}`;
914
+ line = `${prefix}${item.id}${variants}${backing}${badgeText}${disabledSuffix}`;
831
915
  } else if (showProvider) {
832
916
  const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
833
- line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}`;
917
+ line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}${disabledSuffix}`;
834
918
  } else {
835
- line = `${prefix}${item.id}${badgeText}`;
919
+ line = `${prefix}${item.id}${badgeText}${disabledSuffix}`;
836
920
  }
837
921
  }
838
922
 
839
- this.#listContainer.addChild(new Text(line, 0, 0));
923
+ if (isDisabled) {
924
+ line = theme.fg("dim", Bun.stripANSI(line));
925
+ }
926
+ rows.push(line);
840
927
  }
841
928
 
842
- // Add scroll indicator if needed
843
- if (startIndex > 0 || endIndex < visibleItems.length) {
844
- const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${visibleItems.length})`);
845
- this.#listContainer.addChild(new Text(scrollInfo, 0, 0));
929
+ if (rows.length > 0) {
930
+ const sv = new ScrollView(rows, {
931
+ height: rows.length,
932
+ scrollbar: "auto",
933
+ totalRows: visibleItems.length,
934
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
935
+ });
936
+ sv.setScrollOffset(startIndex);
937
+ this.#listContainer.addChild(sv);
846
938
  }
847
939
 
848
940
  // Show error message or "no results" if empty
@@ -863,8 +955,14 @@ export class ModelSelectorComponent extends Container {
863
955
  const suffix = isCanonicalTab
864
956
  ? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)`
865
957
  : "";
958
+ const limitWarning = this.#isItemDisabled(selected)
959
+ ? theme.fg(
960
+ "dim",
961
+ ` — current context ${formatNumber(this.#currentContextTokens).toLowerCase()} > ${formatNumber(selected.model.contextWindow).toLowerCase()} limit`,
962
+ )
963
+ : "";
866
964
  this.#listContainer.addChild(
867
- new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`), 0, 0),
965
+ new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`) + limitWarning, 0, 0),
868
966
  );
869
967
  }
870
968
  }
@@ -890,7 +988,8 @@ export class ModelSelectorComponent extends Container {
890
988
  }
891
989
 
892
990
  #openMenu(): void {
893
- if (!this.#getSelectedItem()) return;
991
+ const selectedItem = this.#getSelectedItem();
992
+ if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
894
993
 
895
994
  this.#isMenuOpen = true;
896
995
  this.#menuStep = "role";
@@ -978,26 +1077,20 @@ export class ModelSelectorComponent extends Container {
978
1077
 
979
1078
  // Up arrow - navigate list (wrap to bottom when at top)
980
1079
  if (matchesSelectUp(keyData)) {
981
- const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
982
- if (itemCount === 0) return;
983
- this.#selectedIndex = this.#selectedIndex === 0 ? itemCount - 1 : this.#selectedIndex - 1;
984
- this.#updateList();
1080
+ this.#moveSelection(-1);
985
1081
  return;
986
1082
  }
987
1083
 
988
1084
  // Down arrow - navigate list (wrap to top when at bottom)
989
1085
  if (matchesSelectDown(keyData)) {
990
- const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
991
- if (itemCount === 0) return;
992
- this.#selectedIndex = this.#selectedIndex === itemCount - 1 ? 0 : this.#selectedIndex + 1;
993
- this.#updateList();
1086
+ this.#moveSelection(1);
994
1087
  return;
995
1088
  }
996
1089
 
997
1090
  // Enter - open context menu or select directly in temporary mode
998
1091
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
999
1092
  const selectedItem = this.#getSelectedItem();
1000
- if (selectedItem) {
1093
+ if (selectedItem && !this.#isItemDisabled(selectedItem)) {
1001
1094
  if (this.#temporaryOnly) {
1002
1095
  // In temporary mode, skip menu and select directly
1003
1096
  this.#handleSelect(selectedItem, null);
@@ -1020,7 +1113,7 @@ export class ModelSelectorComponent extends Container {
1020
1113
  }
1021
1114
  #handleMenuInput(keyData: string): void {
1022
1115
  const selectedItem = this.#getSelectedItem();
1023
- if (!selectedItem) return;
1116
+ if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
1024
1117
 
1025
1118
  const optionCount =
1026
1119
  this.#menuStep === "thinking" && this.#menuSelectedRole !== null
@@ -1079,6 +1172,9 @@ export class ModelSelectorComponent extends Container {
1079
1172
  role: string | null,
1080
1173
  thinkingLevel?: ConfiguredThinkingLevel,
1081
1174
  ): void {
1175
+ if (this.#isItemDisabled(item)) {
1176
+ return;
1177
+ }
1082
1178
  // For temporary role, don't save to settings - just notify caller
1083
1179
  if (role === null) {
1084
1180
  this.#onSelectCallback(item.model, null, undefined, item.selector);
@@ -1,6 +1,14 @@
1
1
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
2
2
  import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/utils/oauth/types";
3
- import { Container, extractPrintableText, fuzzyFilter, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
3
+ import {
4
+ Container,
5
+ extractPrintableText,
6
+ fuzzyFilter,
7
+ matchesKey,
8
+ ScrollView,
9
+ Spacer,
10
+ TruncatedText,
11
+ } from "@oh-my-pi/pi-tui";
4
12
  import { theme } from "../../modes/theme/theme";
5
13
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
6
14
  import type { AuthStorage } from "../../session/auth-storage";
@@ -162,14 +170,10 @@ export class OAuthSelectorComponent extends Container {
162
170
  return this.#isSearchEnabled() || this.#searchQuery.length > 0;
163
171
  }
164
172
 
165
- #renderStatusLine(total: number): string {
166
- const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1;
167
- const count =
168
- this.#searchQuery.trim() && total !== this.#allProviders.length
169
- ? `${selectedCount}/${total} of ${this.#allProviders.length}`
170
- : `${selectedCount}/${total}`;
171
- const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search";
172
- return theme.fg("muted", ` (${count})${suffix}`);
173
+ #renderStatusLine(_total: number): string {
174
+ const query = this.#searchQuery.trim();
175
+ const suffix = query ? `Search: ${this.#searchQuery}` : "Type to search";
176
+ return theme.fg("muted", ` ${suffix}`);
173
177
  }
174
178
 
175
179
  #getProviderSearchText(provider: OAuthProviderInfo): string {
@@ -223,6 +227,7 @@ export class OAuthSelectorComponent extends Container {
223
227
  : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
224
228
  const endIndex = Math.min(startIndex + maxVisible, total);
225
229
 
230
+ const rows: string[] = [];
226
231
  for (let i = startIndex; i < endIndex; i++) {
227
232
  const provider = this.#filteredProviders[i];
228
233
  if (!provider) continue;
@@ -239,11 +244,22 @@ export class OAuthSelectorComponent extends Container {
239
244
  const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
240
245
  line = text + statusIndicator;
241
246
  }
242
- this.#listContainer.addChild(new TruncatedText(line, 0, 0));
247
+ rows.push(line);
243
248
  }
244
249
 
245
- // Scroll/search indicator when list is windowed or searchable
246
- if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
250
+ if (rows.length > 0) {
251
+ const sv = new ScrollView(rows, {
252
+ height: rows.length,
253
+ scrollbar: "auto",
254
+ totalRows: total,
255
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
256
+ });
257
+ sv.setScrollOffset(startIndex);
258
+ this.#listContainer.addChild(sv);
259
+ }
260
+
261
+ // Search status line (scrollbar covers overflow indication)
262
+ if (this.#shouldRenderSearchStatus()) {
247
263
  this.#listContainer.addChild(new TruncatedText(this.#renderStatusLine(total), 0, 0));
248
264
  }
249
265
 
@@ -15,7 +15,7 @@
15
15
  * - Enter on main session -> close overlay (jump back)
16
16
  */
17
17
  import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
18
- import { Container, Markdown, type MarkdownTheme, matchesKey } from "@oh-my-pi/pi-tui";
18
+ import { Container, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
19
19
  import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
20
20
  import type { KeyId } from "../../config/keybindings";
21
21
  import { isSilentAbort } from "../../session/messages";
@@ -230,23 +230,21 @@ export class SessionObserverOverlayComponent extends Container {
230
230
  lines.push(...new DynamicBorder().render(width));
231
231
 
232
232
  // --- Scrolled content viewport ---
233
- const visibleLines = this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight);
234
- for (const vl of visibleLines) {
235
- lines.push(` ${vl}`);
236
- }
237
- // Pad to fill viewport if content is shorter
238
- const pad = this.#viewportHeight - visibleLines.length;
239
- for (let i = 0; i < pad; i++) {
240
- lines.push("");
241
- }
233
+ const sv = new ScrollView(
234
+ this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
235
+ {
236
+ height: this.#viewportHeight,
237
+ scrollbar: "auto",
238
+ totalRows: this.#renderedLines.length,
239
+ theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
240
+ },
241
+ );
242
+ sv.setScrollOffset(this.#scrollOffset);
243
+ for (const row of sv.render(Math.max(1, width - 1))) lines.push(` ${row}`);
242
244
 
243
245
  // --- Footer ---
244
- const scrollInfo =
245
- this.#renderedLines.length > this.#viewportHeight
246
- ? ` ${theme.fg("dim", `[${this.#scrollOffset + 1}-${Math.min(this.#scrollOffset + this.#viewportHeight, this.#renderedLines.length)}/${this.#renderedLines.length}]`)}`
247
- : "";
248
246
  lines.push("");
249
- lines.push(` ${this.#viewerFooterLines[0] ?? ""}${scrollInfo}`);
247
+ lines.push(` ${this.#viewerFooterLines[0] ?? ""}`);
250
248
  for (let i = 1; i < this.#viewerFooterLines.length; i++) {
251
249
  lines.push(` ${this.#viewerFooterLines[i]}`);
252
250
  }
@@ -6,6 +6,7 @@ import {
6
6
  matchesKey,
7
7
  padding,
8
8
  replaceTabs,
9
+ ScrollView,
9
10
  Spacer,
10
11
  Text,
11
12
  truncateToWidth,
@@ -247,6 +248,11 @@ class SessionList implements Component {
247
248
  const endIndex = Math.min(startIndex + maxVisible, this.#filteredSessions.length);
248
249
 
249
250
  // Render visible sessions (3 lines, or 4 when a title adds a preview line).
251
+ // Each session block is built into sessionLines, then wrapped by ScrollView
252
+ // so the right-edge scrollbar is proportional at the physical-line level.
253
+ const sessionLines: string[] = [];
254
+ const overflow = this.#filteredSessions.length > maxVisible;
255
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
250
256
  for (let i = startIndex; i < endIndex; i++) {
251
257
  const session = this.#filteredSessions[i];
252
258
  const isSelected = i === this.#selectedIndex;
@@ -258,22 +264,22 @@ class SessionList implements Component {
258
264
  const cursorSymbol = `${theme.nav.cursor} `;
259
265
  const cursorWidth = visibleWidth(cursorSymbol);
260
266
  const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth);
261
- const maxWidth = width - cursorWidth; // Account for cursor width
267
+ const maxWidth = rowWidth - cursorWidth; // Account for cursor width
262
268
 
263
269
  if (session.title) {
264
270
  // Has title: show title on first line, dimmed first message on second line
265
271
  const truncatedTitle = truncateToWidth(session.title, maxWidth);
266
272
  const titleLine = cursor + (isSelected ? theme.bold(truncatedTitle) : truncatedTitle);
267
- lines.push(titleLine);
273
+ sessionLines.push(titleLine);
268
274
 
269
275
  // Second line: dimmed first message preview
270
276
  const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth);
271
- lines.push(` ${theme.fg("dim", truncatedPreview)}`);
277
+ sessionLines.push(` ${theme.fg("dim", truncatedPreview)}`);
272
278
  } else {
273
279
  // No title: show first message as main line
274
280
  const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth);
275
281
  const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
276
- lines.push(messageLine);
282
+ sessionLines.push(messageLine);
277
283
  }
278
284
 
279
285
  // Metadata line: date + file size + lifecycle status (+ project dir in
@@ -290,18 +296,23 @@ class SessionList implements Component {
290
296
  if (this.#showCwd && session.cwd) {
291
297
  metadata += ` ${dot} ${dim(shortenPath(session.cwd))}`;
292
298
  }
293
- const metadataLine = truncateToWidth(metadata, width);
299
+ const metadataLine = truncateToWidth(metadata, rowWidth);
294
300
 
295
- lines.push(metadataLine);
296
- lines.push(""); // Blank line between sessions
301
+ sessionLines.push(metadataLine);
302
+ sessionLines.push(""); // Blank line between sessions
297
303
  }
298
304
 
299
- // Add scroll indicator if needed
300
- if (startIndex > 0 || endIndex < this.#filteredSessions.length) {
301
- const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredSessions.length})`;
302
- const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width));
303
- lines.push(scrollInfo);
304
- }
305
+ // Wrap the rendered window in a ScrollView for a proportional right-edge bar.
306
+ const visibleCount = endIndex - startIndex;
307
+ const linesPerItem = visibleCount > 0 ? sessionLines.length / visibleCount : 1;
308
+ const sv = new ScrollView(sessionLines, {
309
+ height: sessionLines.length,
310
+ scrollbar: "auto",
311
+ totalRows: Math.round(this.#filteredSessions.length * linesPerItem),
312
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
313
+ });
314
+ sv.setScrollOffset(Math.round(startIndex * linesPerItem));
315
+ lines.push(...sv.render(width));
305
316
 
306
317
  // Add keybinding hint
307
318
  lines.push("");
@@ -546,7 +546,7 @@ export class StatusLineComponent implements Component {
546
546
  return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
547
547
  }
548
548
 
549
- #buildSegmentContext(width: number): SegmentContext {
549
+ #buildSegmentContext(width: number, segmentOptions: StatusLineSettings["segmentOptions"]): SegmentContext {
550
550
  const state = this.session.state;
551
551
 
552
552
  // Trigger background fetch (5-min TTL); render uses cached value
@@ -575,7 +575,7 @@ export class StatusLineComponent implements Component {
575
575
  return {
576
576
  session: this.session,
577
577
  width,
578
- options: this.#resolveSettings().segmentOptions ?? {},
578
+ options: segmentOptions ?? {},
579
579
  planMode: this.#planModeStatus,
580
580
  loopMode: this.#loopModeStatus,
581
581
  goalMode: this.#goalModeStatus,
@@ -632,8 +632,8 @@ export class StatusLineComponent implements Component {
632
632
  }
633
633
 
634
634
  #buildStatusLine(width: number): string {
635
- const ctx = this.#buildSegmentContext(width);
636
635
  const effectiveSettings = this.#resolveSettings();
636
+ const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions);
637
637
  const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
638
638
 
639
639
  const bgAnsi = theme.getBgAnsi("statusLineBg");
@@ -759,8 +759,6 @@ export class StatusLineComponent implements Component {
759
759
  return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
760
760
  }
761
761
 
762
- leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
763
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
764
762
  const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
765
763
  const sessionName =
766
764
  effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;