@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.
- package/CHANGELOG.md +146 -198
- package/dist/types/async/job-manager.d.ts +3 -3
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/claude-trace-cli.d.ts +54 -0
- package/dist/types/cli/session-picker.d.ts +10 -3
- package/dist/types/cli/update-cli.d.ts +17 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/config/settings.d.ts +13 -0
- package/dist/types/eval/concurrency-bridge.d.ts +26 -0
- package/dist/types/eval/js/tool-bridge.d.ts +2 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
- package/dist/types/main.d.ts +5 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/hook-selector.d.ts +3 -0
- package/dist/types/modes/components/session-selector.d.ts +32 -5
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +9 -2
- package/dist/types/modes/types.d.ts +4 -2
- package/dist/types/registry/agent-registry.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +4 -2
- package/dist/types/session/history-storage.d.ts +16 -1
- package/dist/types/session/session-manager.d.ts +4 -0
- package/dist/types/task/output-manager.d.ts +6 -15
- package/dist/types/tools/find.d.ts +0 -9
- package/dist/types/tools/index.d.ts +1 -1
- package/dist/types/tools/path-utils.d.ts +16 -0
- package/dist/types/tools/sqlite-reader.d.ts +25 -8
- package/dist/types/utils/clipboard.d.ts +4 -0
- package/dist/types/web/kagi.d.ts +76 -0
- package/dist/types/web/search/providers/exa.d.ts +7 -1
- package/dist/types/web/search/providers/kagi.d.ts +1 -0
- package/package.json +9 -9
- package/src/async/job-manager.ts +3 -3
- package/src/cli/args.ts +6 -2
- package/src/cli/claude-trace-cli.ts +783 -0
- package/src/cli/session-picker.ts +36 -10
- package/src/cli/update-cli.ts +35 -2
- package/src/commands/launch.ts +3 -0
- package/src/config/keybindings.ts +6 -0
- package/src/config/settings-schema.ts +2 -2
- package/src/config/settings.ts +23 -0
- package/src/discovery/claude-plugins.ts +7 -9
- package/src/eval/__tests__/agent-bridge.test.ts +58 -4
- package/src/eval/concurrency-bridge.ts +34 -0
- package/src/eval/js/shared/prelude.txt +20 -17
- package/src/eval/js/tool-bridge.ts +5 -0
- package/src/eval/py/prelude.py +23 -15
- package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
- package/src/extensibility/skills.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/main.ts +92 -24
- package/src/modes/acp/acp-event-mapper.ts +54 -4
- package/src/modes/components/custom-editor.ts +10 -0
- package/src/modes/components/hook-selector.ts +89 -31
- package/src/modes/components/oauth-selector.ts +12 -6
- package/src/modes/components/session-selector.ts +179 -24
- package/src/modes/components/tool-execution.ts +16 -3
- package/src/modes/controllers/command-controller.ts +2 -11
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +19 -1
- package/src/modes/controllers/selector-controller.ts +61 -21
- package/src/modes/interactive-mode.ts +125 -15
- package/src/modes/types.ts +5 -2
- package/src/prompts/system/orchestrate-notice.md +5 -3
- package/src/prompts/system/workflow-notice.md +2 -2
- package/src/prompts/tools/eval.md +5 -5
- package/src/prompts/tools/find.md +1 -1
- package/src/prompts/tools/irc.md +6 -6
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/task.md +1 -1
- package/src/registry/agent-registry.ts +1 -1
- package/src/sdk.ts +85 -31
- package/src/session/agent-session.ts +62 -46
- package/src/session/history-storage.ts +56 -12
- package/src/session/session-manager.ts +34 -0
- package/src/task/output-manager.ts +40 -48
- package/src/task/render.ts +3 -8
- package/src/tools/browser/tab-worker.ts +8 -5
- package/src/tools/find.ts +5 -29
- package/src/tools/index.ts +1 -1
- package/src/tools/path-utils.ts +144 -1
- package/src/tools/read.ts +47 -0
- package/src/tools/search.ts +2 -27
- package/src/tools/sqlite-reader.ts +92 -9
- package/src/utils/clipboard.ts +38 -1
- package/src/utils/open.ts +37 -2
- package/src/web/kagi.ts +168 -49
- package/src/web/search/providers/anthropic.ts +1 -1
- package/src/web/search/providers/exa.ts +20 -86
- 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
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
406
|
-
if (
|
|
407
|
-
throw new Error(
|
|
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
|
-
|
|
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"
|
|
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
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
|
857
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
442
|
-
|
|
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:
|
|
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.#
|
|
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
|
-
#
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
|
316
|
-
if (
|
|
317
|
-
|
|
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(
|
|
394
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
|
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
|
}
|