@oh-my-pi/pi-coding-agent 15.7.5 → 15.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 (146) hide show
  1. package/CHANGELOG.md +159 -182
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
@@ -7,6 +7,7 @@ import {
7
7
  extractPrintableText,
8
8
  fuzzyFilter,
9
9
  Markdown,
10
+ type MarkdownTheme,
10
11
  matchesKey,
11
12
  padding,
12
13
  renderInlineMarkdown,
@@ -14,8 +15,8 @@ import {
14
15
  Spacer,
15
16
  Text,
16
17
  type TUI,
17
- truncateToWidth,
18
18
  visibleWidth,
19
+ wrapTextWithAnsi,
19
20
  } from "@oh-my-pi/pi-tui";
20
21
  import { getMarkdownTheme, type ThemeColor, theme } from "../../modes/theme/theme";
21
22
  import {
@@ -67,6 +68,37 @@ export interface HookSelectorOptions {
67
68
  onExternalEditor?: () => void;
68
69
  helpText?: string;
69
70
  slider?: HookSelectorSlider;
71
+ /** Indices into the original options that cannot be selected: they render
72
+ * dimmed, are skipped during navigation, and reject enter/timeout. */
73
+ disabledIndices?: readonly number[];
74
+ }
75
+
76
+ export interface HookSelectorOption {
77
+ label: string;
78
+ description?: string;
79
+ }
80
+
81
+ export type HookSelectorOptionInput = string | HookSelectorOption;
82
+
83
+ function normalizeHookSelectorOption(option: HookSelectorOptionInput): HookSelectorOption {
84
+ if (typeof option === "string") return { label: option };
85
+ if (option.description?.trim()) {
86
+ return { label: option.label, description: option.description.trim() };
87
+ }
88
+ return { label: option.label };
89
+ }
90
+
91
+ function splitLeadingSpacesForWrap(line: string, width: number): { indent: string; body: string } {
92
+ let indentLength = 0;
93
+ while (indentLength < line.length && line.charCodeAt(indentLength) === 32) {
94
+ indentLength += 1;
95
+ }
96
+ const maxIndentLength = Math.max(0, width - 1);
97
+ const clampedIndentLength = Math.min(indentLength, maxIndentLength);
98
+ return {
99
+ indent: line.slice(0, clampedIndentLength),
100
+ body: line.slice(indentLength),
101
+ };
70
102
  }
71
103
 
72
104
  class OutlinedList extends Container {
@@ -81,21 +113,33 @@ class OutlinedList extends Container {
81
113
  const borderColor = (text: string) => theme.fg("border", text);
82
114
  const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
83
115
  const innerWidth = Math.max(1, width - 2);
84
- const content = this.#lines.map(line => {
116
+ const content: string[] = [];
117
+ for (const line of this.#lines) {
85
118
  const normalized = replaceTabs(line);
86
- const fitted = truncateToWidth(normalized, innerWidth);
87
- const pad = Math.max(0, innerWidth - visibleWidth(fitted));
88
- return `${borderColor(theme.boxSharp.vertical)}${fitted}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`;
89
- });
119
+ const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
120
+ const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
121
+ for (const wrappedBody of wrapped.length > 0 ? wrapped : [""]) {
122
+ const wrappedLine = `${indent}${wrappedBody}`;
123
+ const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine));
124
+ content.push(
125
+ `${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`,
126
+ );
127
+ }
128
+ }
90
129
  return [horizontal, ...content, horizontal];
91
130
  }
92
131
  }
93
132
 
133
+ /** A filtered option paired with its index into the original options array, so
134
+ * disabled-index lookups survive fuzzy filtering and reordering. */
135
+ type FilteredOption = { option: HookSelectorOption; index: number };
136
+
94
137
  export class HookSelectorComponent extends Container {
95
- #options: string[];
96
- #filteredOptions: string[];
138
+ #options: HookSelectorOption[];
139
+ #filteredOptions: FilteredOption[];
97
140
  #searchQuery = "";
98
141
  #selectedIndex: number;
142
+ #disabledIndices: Set<number>;
99
143
  #maxVisible: number;
100
144
  #listContainer: Container | undefined;
101
145
  #outlinedList: OutlinedList | undefined;
@@ -110,18 +154,24 @@ export class HookSelectorComponent extends Container {
110
154
  #slider: HookSelectorSlider | undefined;
111
155
  #sliderIndex: number = 0;
112
156
  #sliderComponent: Text | undefined;
157
+ #lastRenderWidth: number | undefined;
113
158
  constructor(
114
159
  title: string,
115
- options: string[],
160
+ options: HookSelectorOptionInput[],
116
161
  onSelect: (option: string) => void,
117
162
  onCancel: () => void,
118
163
  opts?: HookSelectorOptions,
119
164
  ) {
120
165
  super();
121
166
 
122
- this.#options = options;
123
- this.#filteredOptions = options;
124
- this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, this.#filteredOptions.length - 1);
167
+ this.#options = options.map(normalizeHookSelectorOption);
168
+ this.#filteredOptions = this.#options.map((option, index) => ({ option, index }));
169
+ this.#disabledIndices = new Set(
170
+ (opts?.disabledIndices ?? []).filter(
171
+ index => Number.isInteger(index) && index >= 0 && index < this.#options.length,
172
+ ),
173
+ );
174
+ this.#selectedIndex = this.#coerceSelectedIndex(opts?.initialIndex ?? 0);
125
175
  this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
126
176
  this.#onSelectCallback = onSelect;
127
177
  this.#onCancelCallback = onCancel;
@@ -154,9 +204,10 @@ export class HookSelectorComponent extends Container {
154
204
  s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
155
205
  () => {
156
206
  opts?.onTimeout?.();
207
+ // Auto-select current option on timeout (typically the first/recommended option)
157
208
  const selected = this.#filteredOptions[this.#selectedIndex];
158
- if (selected) {
159
- this.#onSelectCallback(selected);
209
+ if (selected && !this.#isDisabled(selected.index)) {
210
+ this.#onSelectCallback(selected.option.label);
160
211
  } else {
161
212
  this.#onCancelCallback();
162
213
  }
@@ -180,32 +231,160 @@ export class HookSelectorComponent extends Container {
180
231
  this.#updateList();
181
232
  }
182
233
 
183
- #updateList(): void {
234
+ #isDisabled(index: number): boolean {
235
+ return this.#disabledIndices.has(index);
236
+ }
237
+
238
+ /** Clamp `index` into range, then walk forward (and finally backward) to the
239
+ * nearest enabled option so the cursor never lands on a disabled row. */
240
+ #coerceSelectedIndex(index: number): number {
241
+ if (this.#filteredOptions.length === 0) return -1;
242
+ const maxIndex = this.#filteredOptions.length - 1;
243
+ const clamped = Math.max(0, Math.min(index, maxIndex));
244
+ const clampedOption = this.#filteredOptions[clamped];
245
+ if (clampedOption && !this.#isDisabled(clampedOption.index)) return clamped;
246
+ for (let i = clamped + 1; i <= maxIndex; i++) {
247
+ const option = this.#filteredOptions[i];
248
+ if (option && !this.#isDisabled(option.index)) return i;
249
+ }
250
+ for (let i = clamped - 1; i >= 0; i--) {
251
+ const option = this.#filteredOptions[i];
252
+ if (option && !this.#isDisabled(option.index)) return i;
253
+ }
254
+ return clamped;
255
+ }
256
+
257
+ /** Move the cursor by `delta`, skipping disabled rows, stopping at the first
258
+ * enabled option reached or at the list edge. */
259
+ #moveSelection(delta: number): void {
260
+ if (this.#filteredOptions.length === 0) return;
261
+ const maxIndex = this.#filteredOptions.length - 1;
262
+ let index = this.#selectedIndex;
263
+ while (true) {
264
+ const next = Math.max(0, Math.min(index + delta, maxIndex));
265
+ if (next === index) return;
266
+ index = next;
267
+ const option = this.#filteredOptions[index];
268
+ if (option && !this.#isDisabled(option.index)) {
269
+ this.#selectedIndex = index;
270
+ this.#updateList();
271
+ return;
272
+ }
273
+ }
274
+ }
275
+
276
+ #renderOptionLines(
277
+ option: HookSelectorOption,
278
+ isSelected: boolean,
279
+ isDisabled: boolean,
280
+ mdTheme: MarkdownTheme,
281
+ ): string[] {
282
+ const textColor = isDisabled ? "dim" : isSelected ? "accent" : "text";
283
+ const prefixColor = isDisabled ? "dim" : "accent";
284
+ const label = renderInlineMarkdown(option.label, mdTheme, t => theme.fg(textColor, t));
285
+ const prefix = isSelected ? theme.fg(prefixColor, `${theme.nav.cursor} `) : " ";
286
+ const lines = [prefix + label];
287
+ if (option.description) {
288
+ const descriptionColor = isDisabled ? "dim" : "muted";
289
+ const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg(descriptionColor, t));
290
+ lines.push(` ${description}`);
291
+ }
292
+ return lines;
293
+ }
294
+
295
+ #renderedLineRowCount(line: string, renderWidth: number): number {
296
+ const normalized = replaceTabs(line);
297
+ if (this.#outlinedList) {
298
+ const innerWidth = Math.max(1, renderWidth - 2);
299
+ const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
300
+ const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
301
+ return Math.max(1, wrapped.length);
302
+ }
303
+ const wrapped = wrapTextWithAnsi(normalized, Math.max(1, renderWidth - 2));
304
+ return Math.max(1, wrapped.length);
305
+ }
306
+
307
+ #optionRowCount(
308
+ option: HookSelectorOption,
309
+ renderWidth: number | undefined,
310
+ isSelected: boolean,
311
+ mdTheme: MarkdownTheme,
312
+ ): number {
313
+ if (renderWidth === undefined) return option.description ? 2 : 1;
314
+ let rows = 0;
315
+ for (const line of this.#renderOptionLines(option, isSelected, false, mdTheme)) {
316
+ rows += this.#renderedLineRowCount(line, renderWidth);
317
+ }
318
+ return rows;
319
+ }
320
+
321
+ #totalOptionRows(options: HookSelectorOption[], renderWidth?: number, mdTheme?: MarkdownTheme): number {
322
+ const themeForRows = mdTheme ?? getMarkdownTheme();
323
+ let rows = 0;
324
+ for (const option of options) {
325
+ rows += this.#optionRowCount(option, renderWidth, false, themeForRows);
326
+ }
327
+ return rows;
328
+ }
329
+
330
+ #getVisibleOptionRange(
331
+ total: number,
332
+ renderWidth?: number,
333
+ mdTheme: MarkdownTheme = getMarkdownTheme(),
334
+ ): { startIndex: number; endIndex: number } {
335
+ if (total === 0) return { startIndex: 0, endIndex: 0 };
336
+
337
+ const rowBudget = Math.max(1, this.#maxVisible);
338
+ const selectedIndex = Math.max(0, Math.min(this.#selectedIndex, total - 1));
339
+ let startIndex = selectedIndex;
340
+ let endIndex = selectedIndex + 1;
341
+ let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!.option, renderWidth, true, mdTheme);
342
+ let beforeRows = 0;
343
+ const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2));
344
+
345
+ while (startIndex > 0) {
346
+ const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme);
347
+ if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break;
348
+ startIndex--;
349
+ beforeRows += cost;
350
+ rows += cost;
351
+ }
352
+
353
+ while (endIndex < total) {
354
+ const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!.option, renderWidth, false, mdTheme);
355
+ if (rows + cost > rowBudget) break;
356
+ endIndex++;
357
+ rows += cost;
358
+ }
359
+
360
+ while (startIndex > 0) {
361
+ const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme);
362
+ if (rows + cost > rowBudget) break;
363
+ startIndex--;
364
+ rows += cost;
365
+ }
366
+
367
+ return { startIndex, endIndex };
368
+ }
369
+
370
+ #updateList(renderWidth = this.#lastRenderWidth): void {
184
371
  const lines: string[] = [];
185
372
  const total = this.#filteredOptions.length;
186
- const startIndex = Math.max(
187
- 0,
188
- Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible),
189
- );
190
- const endIndex = Math.min(startIndex + this.#maxVisible, total);
191
-
192
373
  const mdTheme = getMarkdownTheme();
374
+ const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme);
375
+
193
376
  for (let i = startIndex; i < endIndex; i++) {
194
- const option = this.#filteredOptions[i];
195
- if (option === undefined) continue;
377
+ const filtered = this.#filteredOptions[i];
378
+ if (filtered === undefined) continue;
196
379
  const isSelected = i === this.#selectedIndex;
197
- const label = isSelected
198
- ? renderInlineMarkdown(option, mdTheme, t => theme.fg("accent", t))
199
- : renderInlineMarkdown(option, mdTheme, t => theme.fg("text", t));
200
- const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
201
- lines.push(prefix + label);
380
+ lines.push(...this.#renderOptionLines(filtered.option, isSelected, this.#isDisabled(filtered.index), mdTheme));
202
381
  }
203
382
 
204
383
  if (total === 0) {
205
384
  lines.push(theme.fg("dim", " No matching options"));
206
385
  }
207
386
 
208
- if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
387
+ if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus(renderWidth, mdTheme)) {
209
388
  lines.push(this.#renderStatusLine(total));
210
389
  }
211
390
  if (this.#outlinedList) {
@@ -253,12 +432,12 @@ export class HookSelectorComponent extends Container {
253
432
  slider.onChange?.(next);
254
433
  }
255
434
 
256
- #isSearchEnabled(): boolean {
257
- return this.#options.length > this.#maxVisible;
435
+ #isSearchEnabled(renderWidth = this.#lastRenderWidth, mdTheme?: MarkdownTheme): boolean {
436
+ return this.#totalOptionRows(this.#options, renderWidth, mdTheme) > this.#maxVisible;
258
437
  }
259
438
 
260
- #shouldRenderSearchStatus(): boolean {
261
- return this.#isSearchEnabled() || this.#searchQuery.length > 0;
439
+ #shouldRenderSearchStatus(renderWidth = this.#lastRenderWidth, mdTheme?: MarkdownTheme): boolean {
440
+ return this.#isSearchEnabled(renderWidth, mdTheme) || this.#searchQuery.length > 0;
262
441
  }
263
442
 
264
443
  #renderStatusLine(total: number): string {
@@ -273,8 +452,11 @@ export class HookSelectorComponent extends Container {
273
452
 
274
453
  #setSearchQuery(query: string): void {
275
454
  this.#searchQuery = query;
276
- this.#filteredOptions = query.trim() ? fuzzyFilter(this.#options, query, option => option) : this.#options;
277
- this.#selectedIndex = 0;
455
+ const indexedOptions = this.#options.map((option, index) => ({ option, index }));
456
+ this.#filteredOptions = query.trim()
457
+ ? fuzzyFilter(indexedOptions, query, item => `${item.option.label} ${item.option.description ?? ""}`)
458
+ : indexedOptions;
459
+ this.#selectedIndex = this.#coerceSelectedIndex(0);
278
460
  this.#updateList();
279
461
  }
280
462
 
@@ -311,18 +493,12 @@ export class HookSelectorComponent extends Container {
311
493
  }
312
494
 
313
495
  if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && keyData === "k")) {
314
- if (this.#filteredOptions.length > 0) {
315
- this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
316
- this.#updateList();
317
- }
496
+ this.#moveSelection(-1);
318
497
  } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && keyData === "j")) {
319
- if (this.#filteredOptions.length > 0) {
320
- this.#selectedIndex = Math.min(this.#filteredOptions.length - 1, this.#selectedIndex + 1);
321
- this.#updateList();
322
- }
498
+ this.#moveSelection(1);
323
499
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
324
500
  const selected = this.#filteredOptions[this.#selectedIndex];
325
- if (selected) this.#onSelectCallback(selected);
501
+ if (selected && !this.#isDisabled(selected.index)) this.#onSelectCallback(selected.option.label);
326
502
  } else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) {
327
503
  if (this.#slider) this.#moveSlider(-1);
328
504
  else this.#onLeftCallback?.();
@@ -334,6 +510,15 @@ export class HookSelectorComponent extends Container {
334
510
  }
335
511
  }
336
512
 
513
+ override render(width: number): string[] {
514
+ const renderWidth = Math.max(1, width);
515
+ if (this.#lastRenderWidth !== renderWidth) {
516
+ this.#lastRenderWidth = renderWidth;
517
+ this.#updateList(renderWidth);
518
+ }
519
+ return super.render(renderWidth);
520
+ }
521
+
337
522
  dispose(): void {
338
523
  this.#countdown?.dispose();
339
524
  }
@@ -67,8 +67,14 @@ export class OAuthSelectorComponent extends Container {
67
67
  this.#validationGeneration += 1;
68
68
  this.#stopSpinner();
69
69
  }
70
+ #hasSelectableAuth(providerId: string): boolean {
71
+ return this.#mode === "logout" ? this.#authStorage.has(providerId) : this.#authStorage.hasAuth(providerId);
72
+ }
73
+
70
74
  #loadProviders(): void {
71
- this.#allProviders = getOAuthProviders();
75
+ const providers = getOAuthProviders();
76
+ this.#allProviders =
77
+ this.#mode === "logout" ? providers.filter(provider => this.#hasSelectableAuth(provider.id)) : providers;
72
78
  this.#filteredProviders = this.#allProviders;
73
79
  }
74
80
 
@@ -79,7 +85,7 @@ export class OAuthSelectorComponent extends Container {
79
85
 
80
86
  let pending = 0;
81
87
  for (const provider of this.#allProviders) {
82
- if (!this.#authStorage.hasAuth(provider.id)) {
88
+ if (!this.#hasSelectableAuth(provider.id)) {
83
89
  this.#authState.delete(provider.id);
84
90
  continue;
85
91
  }
@@ -145,8 +151,9 @@ export class OAuthSelectorComponent extends Container {
145
151
  if (state === "valid") {
146
152
  return theme.fg("success", ` ${theme.status.success} logged in`);
147
153
  }
148
- return this.#authStorage.hasAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
154
+ return this.#hasSelectableAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
149
155
  }
156
+
150
157
  #isSearchEnabled(): boolean {
151
158
  return this.#allProviders.length > OAUTH_SELECTOR_MAX_VISIBLE;
152
159
  }
@@ -167,7 +174,7 @@ export class OAuthSelectorComponent extends Container {
167
174
 
168
175
  #getProviderSearchText(provider: OAuthProviderInfo): string {
169
176
  let text = `${provider.name} ${provider.id}`;
170
- if (this.#authStorage.hasAuth(provider.id)) {
177
+ if (this.#hasSelectableAuth(provider.id)) {
171
178
  text += " logged in authenticated";
172
179
  }
173
180
  if (!provider.available) {
@@ -240,13 +247,12 @@ export class OAuthSelectorComponent extends Container {
240
247
  this.#listContainer.addChild(new TruncatedText(this.#renderStatusLine(total), 0, 0));
241
248
  }
242
249
 
243
- // Show "no providers" if empty
244
250
  if (total === 0) {
245
251
  const message =
246
252
  this.#allProviders.length === 0
247
253
  ? this.#mode === "login"
248
254
  ? "No OAuth providers available"
249
- : "No OAuth providers logged in. Use /login first."
255
+ : "No stored provider credentials to log out"
250
256
  : "No matching providers";
251
257
  this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
252
258
  }