@oh-my-pi/pi-coding-agent 15.9.5 → 15.9.67

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 (98) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/keybindings.d.ts +4 -1
  3. package/dist/types/config/settings-schema.d.ts +11 -1
  4. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  5. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  6. package/dist/types/eval/backend.d.ts +6 -6
  7. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  8. package/dist/types/eval/idle-timeout.d.ts +16 -14
  9. package/dist/types/eval/js/executor.d.ts +3 -3
  10. package/dist/types/eval/py/executor.d.ts +2 -2
  11. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  13. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  14. package/dist/types/modes/components/model-selector.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +1 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  20. package/dist/types/tools/eval-render.d.ts +8 -0
  21. package/dist/types/tools/render-utils.d.ts +25 -0
  22. package/dist/types/tui/code-cell.d.ts +6 -0
  23. package/dist/types/tui/output-block.d.ts +11 -0
  24. package/package.json +9 -9
  25. package/src/autoresearch/dashboard.ts +11 -21
  26. package/src/cli/claude-trace-cli.ts +13 -1
  27. package/src/config/keybindings.ts +58 -1
  28. package/src/config/settings-schema.ts +11 -1
  29. package/src/debug/raw-sse.ts +18 -4
  30. package/src/edit/file-snapshot-store.ts +1 -1
  31. package/src/edit/index.ts +1 -1
  32. package/src/edit/renderer.ts +7 -7
  33. package/src/edit/streaming.ts +1 -1
  34. package/src/eval/__tests__/agent-bridge.test.ts +28 -27
  35. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  36. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  37. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  38. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  39. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  40. package/src/eval/agent-bridge.ts +4 -5
  41. package/src/eval/backend.ts +6 -6
  42. package/src/eval/bridge-timeout.ts +44 -0
  43. package/src/eval/idle-timeout.ts +33 -15
  44. package/src/eval/js/executor.ts +10 -10
  45. package/src/eval/llm-bridge.ts +4 -5
  46. package/src/eval/py/executor.ts +6 -6
  47. package/src/eval/py/kernel.ts +11 -1
  48. package/src/eval/py/spawn-options.ts +126 -0
  49. package/src/export/ttsr.ts +9 -0
  50. package/src/extensibility/extensions/runner.ts +2 -0
  51. package/src/internal-urls/docs-index.generated.ts +6 -5
  52. package/src/lsp/client.ts +80 -2
  53. package/src/lsp/index.ts +38 -4
  54. package/src/lsp/render.ts +3 -3
  55. package/src/main.ts +1 -1
  56. package/src/modes/components/agent-dashboard.ts +13 -4
  57. package/src/modes/components/assistant-message.ts +22 -1
  58. package/src/modes/components/copy-selector.ts +249 -0
  59. package/src/modes/components/extensions/extension-list.ts +17 -8
  60. package/src/modes/components/history-search.ts +19 -11
  61. package/src/modes/components/model-selector.ts +125 -29
  62. package/src/modes/components/oauth-selector.ts +28 -12
  63. package/src/modes/components/session-observer-overlay.ts +13 -15
  64. package/src/modes/components/session-selector.ts +24 -13
  65. package/src/modes/components/tool-execution.ts +27 -13
  66. package/src/modes/components/tree-selector.ts +19 -7
  67. package/src/modes/components/user-message-selector.ts +25 -14
  68. package/src/modes/controllers/command-controller.ts +0 -116
  69. package/src/modes/controllers/event-controller.ts +26 -10
  70. package/src/modes/controllers/selector-controller.ts +38 -1
  71. package/src/modes/interactive-mode.ts +4 -4
  72. package/src/modes/theme/theme.ts +46 -10
  73. package/src/modes/types.ts +1 -1
  74. package/src/modes/utils/copy-targets.ts +254 -0
  75. package/src/prompts/tools/ast-edit.md +1 -1
  76. package/src/prompts/tools/ast-grep.md +1 -1
  77. package/src/prompts/tools/read.md +1 -1
  78. package/src/prompts/tools/search.md +1 -1
  79. package/src/session/agent-session.ts +6 -2
  80. package/src/slash-commands/builtin-registry.ts +3 -11
  81. package/src/task/render.ts +38 -11
  82. package/src/tools/bash.ts +18 -8
  83. package/src/tools/browser/render.ts +5 -4
  84. package/src/tools/debug.ts +3 -3
  85. package/src/tools/eval-render.ts +24 -9
  86. package/src/tools/eval.ts +14 -19
  87. package/src/tools/fetch.ts +5 -5
  88. package/src/tools/read.ts +7 -7
  89. package/src/tools/render-utils.ts +46 -0
  90. package/src/tools/ssh.ts +21 -8
  91. package/src/tools/write.ts +17 -8
  92. package/src/tui/code-cell.ts +19 -4
  93. package/src/tui/output-block.ts +14 -0
  94. package/src/web/search/render.ts +3 -3
  95. package/dist/types/eval/heartbeat.d.ts +0 -45
  96. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  97. package/src/eval/heartbeat.ts +0 -74
  98. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.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("");
@@ -33,7 +33,7 @@ import {
33
33
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
34
  import { toolRenderers } from "../../tools/renderers";
35
35
  import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
36
- import { renderStatusLine } from "../../tui";
36
+ import { isFramedBlockComponent, renderStatusLine } from "../../tui";
37
37
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
38
38
  import { renderDiff } from "./diff";
39
39
 
@@ -45,6 +45,12 @@ function ensureInvalidate(component: unknown): Component {
45
45
  return c as Component;
46
46
  }
47
47
 
48
+ function addBoxChild(box: Box, component: unknown): boolean {
49
+ const child = ensureInvalidate(component);
50
+ box.addChild(child);
51
+ return isFramedBlockComponent(child);
52
+ }
53
+
48
54
  /**
49
55
  * Drop trailing removal/hunk-header lines that appear in a streaming diff
50
56
  * before the matching `+added` lines have arrived. Without this, a partial
@@ -107,7 +113,7 @@ function rawTextInputFromPartialJson(partialJson: unknown): string | undefined {
107
113
  // Function-tool arguments stream as JSON. Custom/free-form tools stream raw
108
114
  // text in the same transport field; only the raw form is a valid fallback for
109
115
  // the conventional `input` parameter.
110
- if (first === "{" || first === "[" || first === '"') return undefined;
116
+ if (first === "{" || first === '"') return undefined;
111
117
  return partialJson;
112
118
  }
113
119
 
@@ -582,6 +588,7 @@ export class ToolExecutionComponent extends Container {
582
588
  const inline = Boolean((tool as { inline?: boolean }).inline);
583
589
  this.#contentBox.setBgFn(inline ? undefined : bgFn);
584
590
  this.#contentBox.clear();
591
+ let contentBoxHasFramedBlock = false;
585
592
  // Mirror the built-in renderer branch so custom renderers (notably the
586
593
  // task tool, whose live instance routes through here) receive the same
587
594
  // render context — e.g. the `hasResult` flag that suppresses the task
@@ -594,16 +601,16 @@ export class ToolExecutionComponent extends Container {
594
601
  try {
595
602
  const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
596
603
  if (callComponent) {
597
- this.#contentBox.addChild(ensureInvalidate(callComponent));
604
+ contentBoxHasFramedBlock = addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
598
605
  }
599
606
  } catch (err) {
600
607
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
601
608
  // Fall back to default on error
602
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
609
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
603
610
  }
604
611
  } else {
605
612
  // No custom renderCall, show tool name
606
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
613
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
607
614
  }
608
615
 
609
616
  // Render result component if we have a result
@@ -626,23 +633,24 @@ export class ToolExecutionComponent extends Container {
626
633
  this.#args,
627
634
  );
628
635
  if (resultComponent) {
629
- this.#contentBox.addChild(ensureInvalidate(resultComponent));
636
+ contentBoxHasFramedBlock = addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
630
637
  }
631
638
  } catch (err) {
632
639
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
633
640
  // Fall back to showing raw output on error
634
641
  const output = this.#getTextOutput();
635
642
  if (output) {
636
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
643
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
637
644
  }
638
645
  }
639
646
  } else if (this.#result) {
640
647
  // Has result but no custom renderResult
641
648
  const output = this.#getTextOutput();
642
649
  if (output) {
643
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
650
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
644
651
  }
645
652
  }
653
+ this.#contentBox.setPaddingX(contentBoxHasFramedBlock ? 0 : 1);
646
654
  } else if (this.#toolName in toolRenderers) {
647
655
  // Built-in tools with renderers
648
656
  const renderer = toolRenderers[this.#toolName];
@@ -661,6 +669,7 @@ export class ToolExecutionComponent extends Container {
661
669
  // Multi-file: render each file as its own Box (identical to separate tool calls)
662
670
  this.#contentBox.setBgFn(undefined);
663
671
  this.#contentBox.clear();
672
+ this.#contentBox.setPaddingX(1);
664
673
 
665
674
  const renderContext = this.#buildRenderContext();
666
675
  this.#renderState.renderContext = renderContext;
@@ -683,7 +692,8 @@ export class ToolExecutionComponent extends Container {
683
692
  theme,
684
693
  );
685
694
  if (resultComponent) {
686
- fileBox.addChild(ensureInvalidate(resultComponent));
695
+ const fileBoxHasFramedBlock = addBoxChild(fileBox, resultComponent);
696
+ fileBox.setPaddingX(fileBoxHasFramedBlock ? 0 : 1);
687
697
  }
688
698
  } catch (err) {
689
699
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
@@ -719,6 +729,7 @@ export class ToolExecutionComponent extends Container {
719
729
  // Inline renderers skip background styling
720
730
  this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
721
731
  this.#contentBox.clear();
732
+ let contentBoxHasFramedBlock = false;
722
733
 
723
734
  const renderContext = this.#buildRenderContext();
724
735
  this.#renderState.renderContext = renderContext;
@@ -729,12 +740,13 @@ export class ToolExecutionComponent extends Container {
729
740
  try {
730
741
  const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
731
742
  if (callComponent) {
732
- this.#contentBox.addChild(ensureInvalidate(callComponent));
743
+ contentBoxHasFramedBlock =
744
+ addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
733
745
  }
734
746
  } catch (err) {
735
747
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
736
748
  // Fall back to default on error
737
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
749
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
738
750
  }
739
751
  }
740
752
 
@@ -752,17 +764,19 @@ export class ToolExecutionComponent extends Container {
752
764
  this.#getCallArgsForRender(),
753
765
  );
754
766
  if (resultComponent) {
755
- this.#contentBox.addChild(ensureInvalidate(resultComponent));
767
+ contentBoxHasFramedBlock =
768
+ addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
756
769
  }
757
770
  } catch (err) {
758
771
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
759
772
  // Fall back to showing raw output on error
760
773
  const output = this.#getTextOutput();
761
774
  if (output) {
762
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
775
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
763
776
  }
764
777
  }
765
778
  }
779
+ this.#contentBox.setPaddingX(contentBoxHasFramedBlock ? 0 : 1);
766
780
  }
767
781
  } else {
768
782
  // Other built-in tools: use Text directly with caching