@oh-my-pi/pi-coding-agent 15.7.6 → 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 (95) hide show
  1. package/CHANGELOG.md +146 -198
  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 +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  12. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  13. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  14. package/dist/types/main.d.ts +5 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  17. package/dist/types/modes/components/session-selector.d.ts +32 -5
  18. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/interactive-mode.d.ts +9 -2
  22. package/dist/types/modes/types.d.ts +4 -2
  23. package/dist/types/registry/agent-registry.d.ts +1 -1
  24. package/dist/types/sdk.d.ts +2 -2
  25. package/dist/types/session/agent-session.d.ts +4 -2
  26. package/dist/types/session/history-storage.d.ts +16 -1
  27. package/dist/types/session/session-manager.d.ts +4 -0
  28. package/dist/types/task/output-manager.d.ts +6 -15
  29. package/dist/types/tools/find.d.ts +0 -9
  30. package/dist/types/tools/index.d.ts +1 -1
  31. package/dist/types/tools/path-utils.d.ts +16 -0
  32. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  33. package/dist/types/utils/clipboard.d.ts +4 -0
  34. package/dist/types/web/kagi.d.ts +76 -0
  35. package/dist/types/web/search/providers/exa.d.ts +7 -1
  36. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  37. package/package.json +9 -9
  38. package/src/async/job-manager.ts +3 -3
  39. package/src/cli/args.ts +6 -2
  40. package/src/cli/claude-trace-cli.ts +783 -0
  41. package/src/cli/session-picker.ts +36 -10
  42. package/src/cli/update-cli.ts +35 -2
  43. package/src/commands/launch.ts +3 -0
  44. package/src/config/keybindings.ts +6 -0
  45. package/src/config/settings-schema.ts +2 -2
  46. package/src/config/settings.ts +23 -0
  47. package/src/discovery/claude-plugins.ts +7 -9
  48. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  49. package/src/eval/concurrency-bridge.ts +34 -0
  50. package/src/eval/js/shared/prelude.txt +20 -17
  51. package/src/eval/js/tool-bridge.ts +5 -0
  52. package/src/eval/py/prelude.py +23 -15
  53. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  54. package/src/extensibility/skills.ts +0 -1
  55. package/src/internal-urls/docs-index.generated.ts +11 -10
  56. package/src/main.ts +92 -24
  57. package/src/modes/acp/acp-event-mapper.ts +54 -4
  58. package/src/modes/components/custom-editor.ts +10 -0
  59. package/src/modes/components/hook-selector.ts +89 -31
  60. package/src/modes/components/oauth-selector.ts +12 -6
  61. package/src/modes/components/session-selector.ts +179 -24
  62. package/src/modes/components/tool-execution.ts +16 -3
  63. package/src/modes/controllers/command-controller.ts +2 -11
  64. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  65. package/src/modes/controllers/input-controller.ts +19 -1
  66. package/src/modes/controllers/selector-controller.ts +61 -21
  67. package/src/modes/interactive-mode.ts +125 -15
  68. package/src/modes/types.ts +5 -2
  69. package/src/prompts/system/orchestrate-notice.md +5 -3
  70. package/src/prompts/system/workflow-notice.md +2 -2
  71. package/src/prompts/tools/eval.md +5 -5
  72. package/src/prompts/tools/find.md +1 -1
  73. package/src/prompts/tools/irc.md +6 -6
  74. package/src/prompts/tools/search.md +1 -1
  75. package/src/prompts/tools/task.md +1 -1
  76. package/src/registry/agent-registry.ts +1 -1
  77. package/src/sdk.ts +85 -31
  78. package/src/session/agent-session.ts +62 -46
  79. package/src/session/history-storage.ts +56 -12
  80. package/src/session/session-manager.ts +34 -0
  81. package/src/task/output-manager.ts +40 -48
  82. package/src/task/render.ts +3 -8
  83. package/src/tools/browser/tab-worker.ts +8 -5
  84. package/src/tools/find.ts +5 -29
  85. package/src/tools/index.ts +1 -1
  86. package/src/tools/path-utils.ts +144 -1
  87. package/src/tools/read.ts +47 -0
  88. package/src/tools/search.ts +2 -27
  89. package/src/tools/sqlite-reader.ts +92 -9
  90. package/src/utils/clipboard.ts +38 -1
  91. package/src/utils/open.ts +37 -2
  92. package/src/web/kagi.ts +168 -49
  93. package/src/web/search/providers/anthropic.ts +1 -1
  94. package/src/web/search/providers/exa.ts +20 -86
  95. package/src/web/search/providers/kagi.ts +4 -0
package/src/main.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  VERSION,
22
22
  } from "@oh-my-pi/pi-utils";
23
23
  import chalk from "chalk";
24
+ import { reset as resetCapabilities } from "./capability";
24
25
  import type { Args } from "./cli/args";
25
26
  import { applyExtensionFlags, type ExtensionFlagSink } from "./cli/extension-flags";
26
27
  import { processFileArguments } from "./cli/file-processor";
@@ -91,15 +92,11 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
91
92
  }
92
93
  }
93
94
 
94
- const RPC_DEFAULTED_SETTING_PATHS: SettingPath[] = [
95
+ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
95
96
  "todo.enabled",
96
97
  "todo.reminders",
97
98
  "todo.reminders.max",
98
99
  "todo.eager",
99
- "async.enabled",
100
- "async.maxJobs",
101
- "bash.autoBackground.enabled",
102
- "bash.autoBackground.thresholdMs",
103
100
  "task.isolation.mode",
104
101
  "task.isolation.merge",
105
102
  "task.isolation.commits",
@@ -109,18 +106,34 @@ const RPC_DEFAULTED_SETTING_PATHS: SettingPath[] = [
109
106
  "task.maxRecursionDepth",
110
107
  "task.disabledAgents",
111
108
  "task.agentModelOverrides",
112
- // Memory subsystems are off-by-default for RPC hosts; embedders that want
109
+ // Memory subsystems are off-by-default for RPC/ACP hosts; embedders that want
113
110
  // memory should opt in explicitly through their own settings layer.
114
111
  "memory.backend",
115
112
  "memories.enabled",
116
113
  ];
117
114
 
118
- function applyRpcDefaultSettingOverrides(targetSettings: Settings = settings): void {
119
- for (const settingPath of RPC_DEFAULTED_SETTING_PATHS) {
115
+ const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [
116
+ "async.enabled",
117
+ "async.maxJobs",
118
+ "bash.autoBackground.enabled",
119
+ "bash.autoBackground.thresholdMs",
120
+ ];
121
+
122
+ function applyDefaultSettingOverrides(settingPaths: SettingPath[], targetSettings: Settings): void {
123
+ for (const settingPath of settingPaths) {
120
124
  targetSettings.override(settingPath, getDefault(settingPath));
121
125
  }
122
126
  }
123
127
 
128
+ function applyRpcDefaultSettingOverrides(targetSettings: Settings = settings): void {
129
+ applyDefaultSettingOverrides(HOST_DEFAULTED_SETTING_PATHS, targetSettings);
130
+ applyDefaultSettingOverrides(RPC_BACKGROUND_DEFAULTED_SETTING_PATHS, targetSettings);
131
+ }
132
+
133
+ function applyAcpDefaultSettingOverrides(targetSettings: Settings = settings): void {
134
+ applyDefaultSettingOverrides(HOST_DEFAULTED_SETTING_PATHS, targetSettings);
135
+ }
136
+
124
137
  async function readPipedInput(): Promise<string | undefined> {
125
138
  if (process.stdin.isTTY !== false) return undefined;
126
139
  try {
@@ -312,15 +325,19 @@ async function runInteractiveMode(
312
325
  }
313
326
  }
314
327
 
315
- async function promptForkSession(session: SessionInfo): Promise<boolean> {
328
+ type ForkSessionPromptResult = "accepted" | "declined" | "unavailable";
329
+
330
+ type ForkSessionPrompt = (session: SessionInfo) => Promise<ForkSessionPromptResult>;
331
+
332
+ async function promptForkSession(session: SessionInfo): Promise<ForkSessionPromptResult> {
316
333
  if (!process.stdin.isTTY) {
317
- return false;
334
+ return "unavailable";
318
335
  }
319
336
  const message = `Session found in different project: ${session.cwd}. Fork into current directory? [y/N] `;
320
337
  const rl = createInterface({ input: process.stdin, output: process.stdout });
321
338
  try {
322
339
  const answer = (await rl.question(message)).trim().toLowerCase();
323
- return answer === "y" || answer === "yes";
340
+ return answer === "y" || answer === "yes" ? "accepted" : "declined";
324
341
  } finally {
325
342
  rl.close();
326
343
  }
@@ -366,10 +383,12 @@ async function flushChangelogVersion(): Promise<void> {
366
383
  }
367
384
  }
368
385
 
369
- async function createSessionManager(
386
+ /** Resolves CLI session flags into an existing, forked, in-memory, or cancelled session manager. */
387
+ export async function createSessionManager(
370
388
  parsed: Args,
371
389
  cwd: string,
372
390
  activeSettings: Settings = settings,
391
+ askToForkSession: ForkSessionPrompt = promptForkSession,
373
392
  ): Promise<SessionManager | undefined> {
374
393
  if (parsed.fork) {
375
394
  if (parsed.noSession) {
@@ -402,9 +421,17 @@ async function createSessionManager(
402
421
  const normalizedCwd = normalizePathForComparison(cwd);
403
422
  const normalizedMatchCwd = normalizePathForComparison(match.session.cwd || cwd);
404
423
  if (normalizedCwd !== normalizedMatchCwd) {
405
- const shouldFork = await promptForkSession(match.session);
406
- if (!shouldFork) {
407
- throw new Error(`Session "${sessionArg}" is in another project (${match.session.cwd}).`);
424
+ const forkPromptResult = await askToForkSession(match.session);
425
+ if (forkPromptResult === "unavailable") {
426
+ throw new Error(
427
+ `Session "${sessionArg}" is in another project (${match.session.cwd}); run interactively to fork it into the current project.`,
428
+ );
429
+ }
430
+ if (forkPromptResult === "declined") {
431
+ // User declined the cross-project fork prompt. Caller distinguishes
432
+ // this cancellation from the "default new session" undefined return
433
+ // by checking `typeof parsed.resume === "string"`.
434
+ return undefined;
408
435
  }
409
436
  return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
410
437
  }
@@ -776,15 +803,17 @@ export async function runRootCommand(
776
803
  }
777
804
  }
778
805
 
779
- const cwd = getProjectDir();
806
+ let cwd = getProjectDir();
780
807
  const settingsInstance = deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd }));
781
808
  if (parsedArgs.approvalMode) {
782
809
  // Runtime override (not persisted): every settings.get("tools.approvalMode") downstream
783
810
  // sees this value. The wrapper still honours --auto-approve / --yolo on top of it.
784
811
  settingsInstance.override("tools.approvalMode", parsedArgs.approvalMode);
785
812
  }
786
- if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") {
813
+ if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui") {
787
814
  applyRpcDefaultSettingOverrides(settingsInstance);
815
+ } else if (parsedArgs.mode === "acp") {
816
+ applyAcpDefaultSettingOverrides(settingsInstance);
788
817
  }
789
818
  if (parsedArgs.noPty || parsedArgs.mode === "rpc-ui") {
790
819
  Bun.env.PI_NO_PTY = "1";
@@ -812,6 +841,11 @@ export async function runRootCommand(
812
841
  });
813
842
  }
814
843
 
844
+ // Apply --hide-thinking CLI flag (ephemeral, not persisted)
845
+ if (parsedArgs.hideThinking) {
846
+ settingsInstance.override("hideThinkingBlock", true);
847
+ }
848
+
815
849
  await logger.time(
816
850
  "initTheme:final",
817
851
  initTheme,
@@ -846,19 +880,53 @@ export async function runRootCommand(
846
880
  settingsInstance,
847
881
  );
848
882
 
883
+ // User declined the cross-project fork prompt — exit cleanly with a friendly
884
+ // message rather than letting the decline bubble up as an uncaught exception
885
+ // (see issue #1668).
886
+ if (typeof parsedArgs.resume === "string" && !sessionManager) {
887
+ process.stdout.write(`${chalk.dim("Resume cancelled: session is in another project.")}\n`);
888
+ return;
889
+ }
890
+
849
891
  // Handle --resume (no value): show session picker
850
892
  if (parsedArgs.resume === true && !parsedArgs.fork) {
851
- const sessions = await logger.time("SessionManager.list", SessionManager.list, cwd, parsedArgs.sessionDir);
852
- if (sessions.length === 0) {
853
- process.stdout.write(`${chalk.dim("No sessions found")}\n`);
854
- return;
893
+ const folderSessions = await logger.time("SessionManager.list", SessionManager.list, cwd, parsedArgs.sessionDir);
894
+ let preloadedAllSessions: SessionInfo[] | undefined;
895
+ let startInAllScope = false;
896
+ if (folderSessions.length === 0) {
897
+ // Nothing in the current folder — fall back to a global scan so the
898
+ // picker can still open in all-projects scope instead of dead-ending.
899
+ preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
900
+ if (preloadedAllSessions.length === 0) {
901
+ process.stdout.write(`${chalk.dim("No sessions found")}\n`);
902
+ return;
903
+ }
904
+ startInAllScope = true;
855
905
  }
856
- const selectedPath = await logger.time("selectSession", selectSession, sessions);
857
- if (!selectedPath) {
906
+ const selected = await logger.time("selectSession", selectSession, folderSessions, {
907
+ allSessions: preloadedAllSessions,
908
+ startInAllScope,
909
+ });
910
+ if (!selected) {
858
911
  process.stdout.write(`${chalk.dim("No session selected")}\n`);
859
912
  return;
860
913
  }
861
- sessionManager = await SessionManager.open(selectedPath);
914
+ // Resuming a session from another project: switch the process into that
915
+ // project's directory and refresh cwd-derived caches before the session is
916
+ // built, so settings discovery, plugins, and capabilities all scope to it.
917
+ if (selected.cwd && normalizePathForComparison(selected.cwd) !== normalizePathForComparison(getProjectDir())) {
918
+ // Let the original (launch-cwd) plugin-root preload settle first so its
919
+ // late resolution can't clobber the re-warm we trigger below.
920
+ await pluginPreloadPromise.catch(() => {});
921
+ setProjectDir(selected.cwd);
922
+ clearPluginRootsAndCaches();
923
+ resetCapabilities();
924
+ cwd = getProjectDir();
925
+ // Re-scope project settings (.claude/settings.yml etc.) to the resumed
926
+ // project in place so the session is built with its configuration.
927
+ await settingsInstance.reloadForCwd(cwd);
928
+ }
929
+ sessionManager = await SessionManager.open(selected.path);
862
930
  }
863
931
 
864
932
  await pluginPreloadPromise;
@@ -69,6 +69,16 @@ interface CommandContainer {
69
69
  command?: unknown;
70
70
  }
71
71
 
72
+ interface EvalCellContainer {
73
+ cells?: unknown;
74
+ }
75
+
76
+ interface EvalCellLike {
77
+ language?: unknown;
78
+ title?: unknown;
79
+ code?: unknown;
80
+ }
81
+
72
82
  interface PatternContainer {
73
83
  pattern?: unknown;
74
84
  }
@@ -435,11 +445,43 @@ function getToolExecutionEndArgs(
435
445
  }
436
446
 
437
447
  function buildToolStartContent(toolName: string, args: unknown): ToolCallContent[] {
438
- if (!isCommandToolName(toolName)) {
439
- return [];
448
+ const text = buildToolStartText(toolName, args);
449
+ return text ? [textToolCallContent(text)] : [];
450
+ }
451
+
452
+ function buildToolStartText(toolName: string, args: unknown): string | undefined {
453
+ if (isCommandToolName(toolName)) {
454
+ const command = extractStringProperty<CommandContainer>(args, "command");
455
+ return command ? limitText(`$ ${command}`) : undefined;
456
+ }
457
+ if (toolName === "eval") {
458
+ return buildEvalStartText(args);
459
+ }
460
+ return undefined;
461
+ }
462
+
463
+ function buildEvalStartText(args: unknown): string | undefined {
464
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
465
+ return undefined;
466
+ }
467
+ const cells = (args as EvalCellContainer).cells;
468
+ if (!Array.isArray(cells) || cells.length === 0) {
469
+ return undefined;
440
470
  }
441
- const command = extractStringProperty<CommandContainer>(args, "command");
442
- return command ? [textToolCallContent(`$ ${command}`)] : [];
471
+ const lines: string[] = [];
472
+ for (const cell of cells) {
473
+ if (typeof cell !== "object" || cell === null || Array.isArray(cell)) {
474
+ continue;
475
+ }
476
+ const language = extractStringProperty<EvalCellLike>(cell, "language") ?? "?";
477
+ const title = extractStringProperty<EvalCellLike>(cell, "title");
478
+ const code = extractStringProperty<EvalCellLike>(cell, "code");
479
+ if (!code) {
480
+ continue;
481
+ }
482
+ lines.push(title ? `[${language}] ${title}` : `[${language}]`, code);
483
+ }
484
+ return lines.length > 0 ? limitText(lines.join("\n")) : undefined;
443
485
  }
444
486
 
445
487
  function mergeToolUpdateContent(startContent: ToolCallContent[], resultContent: ToolCallContent[]): ToolCallContent[] {
@@ -465,6 +507,14 @@ function isCommandToolName(toolName: string): boolean {
465
507
  }
466
508
 
467
509
  function buildToolTitle(toolName: string, args: unknown, intent: string | undefined): string {
510
+ if (isCommandToolName(toolName)) {
511
+ const commandText = buildToolStartText(toolName, args);
512
+ if (commandText) return commandText;
513
+ }
514
+ if (toolName === "eval") {
515
+ const evalText = buildEvalStartText(args);
516
+ if (evalText) return evalText;
517
+ }
468
518
  const trimmedIntent = intent?.trim();
469
519
  if (trimmedIntent) {
470
520
  return trimmedIntent;
@@ -19,6 +19,7 @@ type ConfigurableEditorAction = Extract<
19
19
  | "app.history.search"
20
20
  | "app.message.dequeue"
21
21
  | "app.clipboard.pasteImage"
22
+ | "app.clipboard.pasteTextRaw"
22
23
  | "app.clipboard.copyPrompt"
23
24
  >;
24
25
 
@@ -38,6 +39,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
38
39
  "app.history.search": ["ctrl+r"],
39
40
  "app.message.dequeue": ["alt+up"],
40
41
  "app.clipboard.pasteImage": ["ctrl+v"],
42
+ "app.clipboard.pasteTextRaw": ["ctrl+shift+v", "alt+shift+v"],
41
43
  "app.clipboard.copyPrompt": ["alt+shift+c"],
42
44
  };
43
45
 
@@ -65,6 +67,8 @@ export class CustomEditor extends Editor {
65
67
  onCopyPrompt?: () => void;
66
68
  /** Called when the configured image-paste shortcut is pressed. */
67
69
  onPasteImage?: () => Promise<boolean>;
70
+ /** Called when the configured raw text-paste shortcut is pressed. */
71
+ onPasteTextRaw?: () => void;
68
72
  /** Called when the configured dequeue shortcut is pressed. */
69
73
  onDequeue?: () => void;
70
74
  /** Called when Caps Lock is pressed. */
@@ -124,6 +128,12 @@ export class CustomEditor extends Editor {
124
128
  return;
125
129
  }
126
130
 
131
+ // Intercept configured raw text paste (fires and handles result)
132
+ if (this.#matchesAction(data, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) {
133
+ this.onPasteTextRaw();
134
+ return;
135
+ }
136
+
127
137
  // Intercept configured external editor shortcut
128
138
  if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
129
139
  this.onExternalEditor();
@@ -68,6 +68,9 @@ export interface HookSelectorOptions {
68
68
  onExternalEditor?: () => void;
69
69
  helpText?: string;
70
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[];
71
74
  }
72
75
 
73
76
  export interface HookSelectorOption {
@@ -127,11 +130,16 @@ class OutlinedList extends Container {
127
130
  }
128
131
  }
129
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
+
130
137
  export class HookSelectorComponent extends Container {
131
138
  #options: HookSelectorOption[];
132
- #filteredOptions: HookSelectorOption[];
139
+ #filteredOptions: FilteredOption[];
133
140
  #searchQuery = "";
134
141
  #selectedIndex: number;
142
+ #disabledIndices: Set<number>;
135
143
  #maxVisible: number;
136
144
  #listContainer: Container | undefined;
137
145
  #outlinedList: OutlinedList | undefined;
@@ -157,8 +165,13 @@ export class HookSelectorComponent extends Container {
157
165
  super();
158
166
 
159
167
  this.#options = options.map(normalizeHookSelectorOption);
160
- this.#filteredOptions = this.#options;
161
- this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, this.#filteredOptions.length - 1);
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);
162
175
  this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
163
176
  this.#onSelectCallback = onSelect;
164
177
  this.#onCancelCallback = onCancel;
@@ -191,9 +204,10 @@ export class HookSelectorComponent extends Container {
191
204
  s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
192
205
  () => {
193
206
  opts?.onTimeout?.();
207
+ // Auto-select current option on timeout (typically the first/recommended option)
194
208
  const selected = this.#filteredOptions[this.#selectedIndex];
195
- if (selected) {
196
- this.#onSelectCallback(selected.label);
209
+ if (selected && !this.#isDisabled(selected.index)) {
210
+ this.#onSelectCallback(selected.option.label);
197
211
  } else {
198
212
  this.#onCancelCallback();
199
213
  }
@@ -217,14 +231,62 @@ export class HookSelectorComponent extends Container {
217
231
  this.#updateList();
218
232
  }
219
233
 
220
- #renderOptionLines(option: HookSelectorOption, isSelected: boolean, mdTheme: MarkdownTheme): string[] {
221
- const label = isSelected
222
- ? renderInlineMarkdown(option.label, mdTheme, t => theme.fg("accent", t))
223
- : renderInlineMarkdown(option.label, mdTheme, t => theme.fg("text", t));
224
- const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
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} `) : " ";
225
286
  const lines = [prefix + label];
226
287
  if (option.description) {
227
- const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg("muted", t));
288
+ const descriptionColor = isDisabled ? "dim" : "muted";
289
+ const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg(descriptionColor, t));
228
290
  lines.push(` ${description}`);
229
291
  }
230
292
  return lines;
@@ -250,7 +312,7 @@ export class HookSelectorComponent extends Container {
250
312
  ): number {
251
313
  if (renderWidth === undefined) return option.description ? 2 : 1;
252
314
  let rows = 0;
253
- for (const line of this.#renderOptionLines(option, isSelected, mdTheme)) {
315
+ for (const line of this.#renderOptionLines(option, isSelected, false, mdTheme)) {
254
316
  rows += this.#renderedLineRowCount(line, renderWidth);
255
317
  }
256
318
  return rows;
@@ -276,12 +338,12 @@ export class HookSelectorComponent extends Container {
276
338
  const selectedIndex = Math.max(0, Math.min(this.#selectedIndex, total - 1));
277
339
  let startIndex = selectedIndex;
278
340
  let endIndex = selectedIndex + 1;
279
- let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!, renderWidth, true, mdTheme);
341
+ let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!.option, renderWidth, true, mdTheme);
280
342
  let beforeRows = 0;
281
343
  const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2));
282
344
 
283
345
  while (startIndex > 0) {
284
- const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme);
346
+ const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme);
285
347
  if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break;
286
348
  startIndex--;
287
349
  beforeRows += cost;
@@ -289,14 +351,14 @@ export class HookSelectorComponent extends Container {
289
351
  }
290
352
 
291
353
  while (endIndex < total) {
292
- const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!, renderWidth, false, mdTheme);
354
+ const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!.option, renderWidth, false, mdTheme);
293
355
  if (rows + cost > rowBudget) break;
294
356
  endIndex++;
295
357
  rows += cost;
296
358
  }
297
359
 
298
360
  while (startIndex > 0) {
299
- const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme);
361
+ const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme);
300
362
  if (rows + cost > rowBudget) break;
301
363
  startIndex--;
302
364
  rows += cost;
@@ -312,9 +374,10 @@ export class HookSelectorComponent extends Container {
312
374
  const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme);
313
375
 
314
376
  for (let i = startIndex; i < endIndex; i++) {
315
- const option = this.#filteredOptions[i];
316
- if (option === undefined) continue;
317
- lines.push(...this.#renderOptionLines(option, i === this.#selectedIndex, mdTheme));
377
+ const filtered = this.#filteredOptions[i];
378
+ if (filtered === undefined) continue;
379
+ const isSelected = i === this.#selectedIndex;
380
+ lines.push(...this.#renderOptionLines(filtered.option, isSelected, this.#isDisabled(filtered.index), mdTheme));
318
381
  }
319
382
 
320
383
  if (total === 0) {
@@ -389,10 +452,11 @@ export class HookSelectorComponent extends Container {
389
452
 
390
453
  #setSearchQuery(query: string): void {
391
454
  this.#searchQuery = query;
455
+ const indexedOptions = this.#options.map((option, index) => ({ option, index }));
392
456
  this.#filteredOptions = query.trim()
393
- ? fuzzyFilter(this.#options, query, option => `${option.label} ${option.description ?? ""}`)
394
- : this.#options;
395
- this.#selectedIndex = 0;
457
+ ? fuzzyFilter(indexedOptions, query, item => `${item.option.label} ${item.option.description ?? ""}`)
458
+ : indexedOptions;
459
+ this.#selectedIndex = this.#coerceSelectedIndex(0);
396
460
  this.#updateList();
397
461
  }
398
462
 
@@ -429,18 +493,12 @@ export class HookSelectorComponent extends Container {
429
493
  }
430
494
 
431
495
  if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && keyData === "k")) {
432
- if (this.#filteredOptions.length > 0) {
433
- this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
434
- this.#updateList();
435
- }
496
+ this.#moveSelection(-1);
436
497
  } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && keyData === "j")) {
437
- if (this.#filteredOptions.length > 0) {
438
- this.#selectedIndex = Math.min(this.#filteredOptions.length - 1, this.#selectedIndex + 1);
439
- this.#updateList();
440
- }
498
+ this.#moveSelection(1);
441
499
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
442
500
  const selected = this.#filteredOptions[this.#selectedIndex];
443
- if (selected) this.#onSelectCallback(selected.label);
501
+ if (selected && !this.#isDisabled(selected.index)) this.#onSelectCallback(selected.option.label);
444
502
  } else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) {
445
503
  if (this.#slider) this.#moveSlider(-1);
446
504
  else this.#onLeftCallback?.();
@@ -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
  }