@oh-my-pi/pi-coding-agent 13.15.3 → 13.16.1
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 +30 -16
- package/package.json +7 -7
- package/src/commit/agentic/tools/analyze-file.ts +1 -0
- package/src/config/model-registry.ts +215 -57
- package/src/config/settings-schema.ts +27 -0
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +7 -0
- package/src/extensibility/extensions/types.ts +10 -1
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/ipy/cancellation.ts +28 -0
- package/src/ipy/executor.ts +252 -77
- package/src/ipy/kernel.ts +181 -35
- package/src/ipy/modules.ts +39 -4
- package/src/modes/acp/acp-agent.ts +1 -0
- package/src/modes/components/hook-editor.ts +57 -8
- package/src/modes/components/model-selector.ts +48 -29
- package/src/modes/components/settings-defs.ts +10 -1
- package/src/modes/components/settings-selector.ts +92 -5
- package/src/modes/controllers/extension-ui-controller.ts +35 -4
- package/src/modes/controllers/input-controller.ts +4 -3
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/interactive-mode.ts +7 -2
- package/src/modes/print-mode.ts +1 -0
- package/src/modes/prompt-action-autocomplete.ts +5 -3
- package/src/modes/rpc/rpc-mode.ts +79 -30
- package/src/modes/rpc/rpc-types.ts +9 -1
- package/src/modes/theme/theme.ts +70 -0
- package/src/modes/types.ts +6 -1
- package/src/prompts/system/custom-system-prompt.md +5 -0
- package/src/prompts/system/system-prompt.md +6 -0
- package/src/prompts/tools/ask.md +1 -0
- package/src/prompts/tools/grep.md +1 -1
- package/src/prompts/tools/hashline.md +20 -5
- package/src/sdk.ts +26 -2
- package/src/session/agent-session.ts +18 -11
- package/src/system-prompt.ts +63 -2
- package/src/task/executor.ts +4 -0
- package/src/task/index.ts +2 -0
- package/src/tools/ask.ts +109 -61
- package/src/tools/ast-edit.ts +2 -16
- package/src/tools/ast-grep.ts +2 -17
- package/src/tools/browser.ts +35 -17
- package/src/tools/find.ts +1 -0
- package/src/tools/grep.ts +25 -34
- package/src/tools/index.ts +3 -0
- package/src/tools/path-utils.ts +7 -0
- package/src/tools/python.ts +3 -2
- package/src/tools/render-utils.ts +27 -0
- package/src/tui/tree-list.ts +51 -22
package/src/ipy/kernel.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { $env, logger, Snowflake } from "@oh-my-pi/pi-utils";
|
|
|
2
2
|
import { $ } from "bun";
|
|
3
3
|
import { Settings } from "../config/settings";
|
|
4
4
|
import { htmlToBasicMarkdown } from "../web/scrapers/types";
|
|
5
|
+
import { createCancellationError, getAbortReason, getExecutionCancellationError } from "./cancellation";
|
|
5
6
|
import { acquireSharedGateway, releaseSharedGateway, shutdownSharedGateway } from "./gateway-coordinator";
|
|
6
7
|
import { loadPythonModules } from "./modules";
|
|
7
8
|
import { PYTHON_PRELUDE } from "./prelude";
|
|
@@ -35,6 +36,94 @@ function getExternalGatewayConfig(): ExternalGatewayConfig | null {
|
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
const STARTUP_CLEANUP_TIMEOUT_MS = 2_000;
|
|
40
|
+
const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;
|
|
41
|
+
|
|
42
|
+
interface KernelLifecycleOptions {
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
deadlineMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface KernelShutdownOptions {
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getRemainingTimeMs(deadlineMs?: number): number | undefined {
|
|
53
|
+
if (deadlineMs === undefined) return undefined;
|
|
54
|
+
return Math.max(0, deadlineMs - Date.now());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function throwIfStartupExecutionFailed(
|
|
58
|
+
result: Pick<KernelExecuteResult, "cancelled" | "status" | "timedOut">,
|
|
59
|
+
signal: AbortSignal | undefined,
|
|
60
|
+
failureMessage: string,
|
|
61
|
+
): void {
|
|
62
|
+
if (result.cancelled) {
|
|
63
|
+
throw getExecutionCancellationError(result, signal, failureMessage);
|
|
64
|
+
}
|
|
65
|
+
if (result.status === "error") {
|
|
66
|
+
throw new Error(failureMessage);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createAbortedSignal(reason: Error): AbortSignal {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
controller.abort(reason);
|
|
73
|
+
return controller.signal;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function combineAbortSignal(
|
|
77
|
+
options: KernelLifecycleOptions,
|
|
78
|
+
timeoutCapMs?: number,
|
|
79
|
+
fallbackReason = "Operation aborted",
|
|
80
|
+
): AbortSignal | undefined {
|
|
81
|
+
if (options.signal?.aborted) {
|
|
82
|
+
return options.signal;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const signals: AbortSignal[] = [];
|
|
86
|
+
if (options.signal) {
|
|
87
|
+
signals.push(options.signal);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const remainingMs = getRemainingTimeMs(options.deadlineMs);
|
|
91
|
+
const timeoutMs =
|
|
92
|
+
remainingMs === undefined
|
|
93
|
+
? timeoutCapMs
|
|
94
|
+
: timeoutCapMs === undefined
|
|
95
|
+
? remainingMs
|
|
96
|
+
: Math.min(remainingMs, timeoutCapMs);
|
|
97
|
+
|
|
98
|
+
if (timeoutMs !== undefined) {
|
|
99
|
+
if (timeoutMs <= 0) {
|
|
100
|
+
return createAbortedSignal(createCancellationError("TimeoutError", fallbackReason));
|
|
101
|
+
}
|
|
102
|
+
signals.push(AbortSignal.timeout(timeoutMs));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (signals.length === 0) return undefined;
|
|
106
|
+
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string): void {
|
|
110
|
+
if (!signal?.aborted) return;
|
|
111
|
+
throw getAbortReason(signal, fallbackReason);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getStartupExecuteOptions(options: KernelLifecycleOptions): Pick<KernelExecuteOptions, "signal" | "timeoutMs"> {
|
|
115
|
+
return {
|
|
116
|
+
signal: combineAbortSignal(options, undefined, "Python kernel startup aborted"),
|
|
117
|
+
timeoutMs: getRemainingTimeMs(options.deadlineMs),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getStartupCleanupTimeoutMs(deadlineMs?: number): number {
|
|
122
|
+
const remainingMs = getRemainingTimeMs(deadlineMs);
|
|
123
|
+
if (remainingMs === undefined || remainingMs <= 0) return STARTUP_CLEANUP_TIMEOUT_MS;
|
|
124
|
+
return Math.min(STARTUP_CLEANUP_TIMEOUT_MS, remainingMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
38
127
|
export interface JupyterHeader {
|
|
39
128
|
msg_id: string;
|
|
40
129
|
session: string;
|
|
@@ -93,7 +182,7 @@ export interface PreludeHelper {
|
|
|
93
182
|
category: string;
|
|
94
183
|
}
|
|
95
184
|
|
|
96
|
-
interface KernelStartOptions {
|
|
185
|
+
interface KernelStartOptions extends KernelLifecycleOptions {
|
|
97
186
|
cwd: string;
|
|
98
187
|
env?: Record<string, string | undefined>;
|
|
99
188
|
useSharedGateway?: boolean;
|
|
@@ -339,9 +428,12 @@ export class PythonKernel {
|
|
|
339
428
|
throw new Error(availability.reason ?? "Python kernel unavailable");
|
|
340
429
|
}
|
|
341
430
|
|
|
431
|
+
const startup = { signal: options.signal, deadlineMs: options.deadlineMs };
|
|
432
|
+
const startupSignal = combineAbortSignal(startup, undefined, "Python kernel startup aborted");
|
|
433
|
+
|
|
342
434
|
const externalConfig = getExternalGatewayConfig();
|
|
343
435
|
if (externalConfig) {
|
|
344
|
-
return PythonKernel.#startWithExternalGateway(externalConfig, options.cwd, options.env);
|
|
436
|
+
return PythonKernel.#startWithExternalGateway(externalConfig, options.cwd, options.env, startup);
|
|
345
437
|
}
|
|
346
438
|
|
|
347
439
|
if (options.useSharedGateway === false) {
|
|
@@ -349,6 +441,7 @@ export class PythonKernel {
|
|
|
349
441
|
}
|
|
350
442
|
|
|
351
443
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
444
|
+
throwIfAborted(startupSignal, "Python kernel startup aborted");
|
|
352
445
|
try {
|
|
353
446
|
const sharedResult = await logger.timeAsync("PythonKernel.start:acquireSharedGateway", () =>
|
|
354
447
|
acquireSharedGateway(options.cwd),
|
|
@@ -357,7 +450,7 @@ export class PythonKernel {
|
|
|
357
450
|
throw new Error("Shared Python gateway unavailable");
|
|
358
451
|
}
|
|
359
452
|
const kernel = await logger.timeAsync("PythonKernel.start:startWithSharedGateway", () =>
|
|
360
|
-
PythonKernel.#startWithSharedGateway(sharedResult.url, options.cwd, options.env),
|
|
453
|
+
PythonKernel.#startWithSharedGateway(sharedResult.url, options.cwd, options.env, startup),
|
|
361
454
|
);
|
|
362
455
|
return kernel;
|
|
363
456
|
} catch (err) {
|
|
@@ -382,16 +475,20 @@ export class PythonKernel {
|
|
|
382
475
|
config: ExternalGatewayConfig,
|
|
383
476
|
cwd: string,
|
|
384
477
|
env?: Record<string, string | undefined>,
|
|
478
|
+
startup: KernelLifecycleOptions = {},
|
|
385
479
|
): Promise<PythonKernel> {
|
|
386
480
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
387
481
|
if (config.token) {
|
|
388
482
|
headers.Authorization = `token ${config.token}`;
|
|
389
483
|
}
|
|
390
484
|
|
|
485
|
+
const startupSignal = combineAbortSignal(startup, undefined, "Python kernel startup aborted");
|
|
486
|
+
throwIfAborted(startupSignal, "Python kernel startup aborted");
|
|
391
487
|
const createResponse = await fetch(`${config.url}/api/kernels`, {
|
|
392
488
|
method: "POST",
|
|
393
489
|
headers,
|
|
394
490
|
body: JSON.stringify({ name: "python3" }),
|
|
491
|
+
signal: startupSignal,
|
|
395
492
|
});
|
|
396
493
|
|
|
397
494
|
if (!createResponse.ok) {
|
|
@@ -412,16 +509,23 @@ export class PythonKernel {
|
|
|
412
509
|
);
|
|
413
510
|
|
|
414
511
|
try {
|
|
415
|
-
await kernel.#connectWebSocket();
|
|
416
|
-
await kernel.#initializeKernelEnvironment(cwd, env);
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
512
|
+
await kernel.#connectWebSocket(startup);
|
|
513
|
+
await kernel.#initializeKernelEnvironment(cwd, env, startup);
|
|
514
|
+
const preludeOptions = getStartupExecuteOptions(startup);
|
|
515
|
+
const preludeResult = await kernel.execute(PYTHON_PRELUDE, {
|
|
516
|
+
...preludeOptions,
|
|
517
|
+
silent: true,
|
|
518
|
+
storeHistory: false,
|
|
519
|
+
});
|
|
520
|
+
throwIfStartupExecutionFailed(
|
|
521
|
+
preludeResult,
|
|
522
|
+
preludeOptions.signal,
|
|
523
|
+
"Failed to initialize Python kernel prelude",
|
|
524
|
+
);
|
|
525
|
+
await loadPythonModules(kernel, { cwd, signal: startup.signal, deadlineMs: startup.deadlineMs });
|
|
422
526
|
return kernel;
|
|
423
527
|
} catch (err: unknown) {
|
|
424
|
-
await kernel.shutdown();
|
|
528
|
+
await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
|
|
425
529
|
throw err;
|
|
426
530
|
}
|
|
427
531
|
}
|
|
@@ -430,12 +534,16 @@ export class PythonKernel {
|
|
|
430
534
|
gatewayUrl: string,
|
|
431
535
|
cwd: string,
|
|
432
536
|
env?: Record<string, string | undefined>,
|
|
537
|
+
startup: KernelLifecycleOptions = {},
|
|
433
538
|
): Promise<PythonKernel> {
|
|
539
|
+
const startupSignal = combineAbortSignal(startup, undefined, "Python kernel startup aborted");
|
|
540
|
+
throwIfAborted(startupSignal, "Python kernel startup aborted");
|
|
434
541
|
const createResponse = await logger.timeAsync("startWithSharedGateway:createKernel", () =>
|
|
435
542
|
fetch(`${gatewayUrl}/api/kernels`, {
|
|
436
543
|
method: "POST",
|
|
437
544
|
headers: { "Content-Type": "application/json" },
|
|
438
545
|
body: JSON.stringify({ name: "python3" }),
|
|
546
|
+
signal: startupSignal,
|
|
439
547
|
}),
|
|
440
548
|
);
|
|
441
549
|
|
|
@@ -458,46 +566,70 @@ export class PythonKernel {
|
|
|
458
566
|
const kernel = new PythonKernel(Snowflake.next(), kernelId, gatewayUrl, Snowflake.next(), "omp", true);
|
|
459
567
|
|
|
460
568
|
try {
|
|
461
|
-
await logger.timeAsync("startWithSharedGateway:connectWS", () => kernel.#connectWebSocket());
|
|
462
|
-
await logger.timeAsync("startWithSharedGateway:initEnv", () =>
|
|
569
|
+
await logger.timeAsync("startWithSharedGateway:connectWS", () => kernel.#connectWebSocket(startup));
|
|
570
|
+
await logger.timeAsync("startWithSharedGateway:initEnv", () =>
|
|
571
|
+
kernel.#initializeKernelEnvironment(cwd, env, startup),
|
|
572
|
+
);
|
|
573
|
+
const preludeOptions = getStartupExecuteOptions(startup);
|
|
463
574
|
const preludeResult = await logger.timeAsync("startWithSharedGateway:prelude", () =>
|
|
464
|
-
kernel.execute(PYTHON_PRELUDE, {
|
|
575
|
+
kernel.execute(PYTHON_PRELUDE, {
|
|
576
|
+
...preludeOptions,
|
|
577
|
+
silent: true,
|
|
578
|
+
storeHistory: false,
|
|
579
|
+
}),
|
|
580
|
+
);
|
|
581
|
+
throwIfStartupExecutionFailed(
|
|
582
|
+
preludeResult,
|
|
583
|
+
preludeOptions.signal,
|
|
584
|
+
"Failed to initialize Python kernel prelude",
|
|
585
|
+
);
|
|
586
|
+
await logger.timeAsync("startWithSharedGateway:loadModules", () =>
|
|
587
|
+
loadPythonModules(kernel, { cwd, signal: startup.signal, deadlineMs: startup.deadlineMs }),
|
|
465
588
|
);
|
|
466
|
-
if (preludeResult.cancelled || preludeResult.status === "error") {
|
|
467
|
-
throw new Error("Failed to initialize Python kernel prelude");
|
|
468
|
-
}
|
|
469
|
-
await logger.timeAsync("startWithSharedGateway:loadModules", () => loadPythonModules(kernel, { cwd }));
|
|
470
589
|
return kernel;
|
|
471
590
|
} catch (err: unknown) {
|
|
472
|
-
await kernel.shutdown();
|
|
591
|
+
await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
|
|
473
592
|
throw err;
|
|
474
593
|
}
|
|
475
594
|
}
|
|
476
595
|
|
|
477
|
-
async #connectWebSocket(): Promise<void> {
|
|
596
|
+
async #connectWebSocket(options: KernelLifecycleOptions = {}): Promise<void> {
|
|
478
597
|
const wsBase = this.gatewayUrl.replace(/^http/, "ws");
|
|
479
598
|
let wsUrl = `${wsBase}/api/kernels/${this.kernelId}/channels`;
|
|
480
599
|
if (this.#authToken) {
|
|
481
600
|
wsUrl += `?token=${encodeURIComponent(this.#authToken)}`;
|
|
482
601
|
}
|
|
483
602
|
|
|
603
|
+
const connectSignal = combineAbortSignal(options, WEBSOCKET_CONNECT_TIMEOUT_MS, "WebSocket connection timeout");
|
|
604
|
+
throwIfAborted(connectSignal, "WebSocket connection timeout");
|
|
605
|
+
|
|
484
606
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
485
607
|
const ws = new WebSocket(wsUrl);
|
|
486
608
|
ws.binaryType = "arraybuffer";
|
|
487
609
|
let settled = false;
|
|
488
610
|
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
settled = true;
|
|
493
|
-
reject(new Error("WebSocket connection timeout"));
|
|
611
|
+
const finalize = (): void => {
|
|
612
|
+
if (connectSignal) {
|
|
613
|
+
connectSignal.removeEventListener("abort", onAbort);
|
|
494
614
|
}
|
|
495
|
-
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const onAbort = () => {
|
|
618
|
+
ws.close();
|
|
619
|
+
if (settled) return;
|
|
620
|
+
settled = true;
|
|
621
|
+
finalize();
|
|
622
|
+
reject(getAbortReason(connectSignal, "WebSocket connection timeout"));
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
if (connectSignal) {
|
|
626
|
+
connectSignal.addEventListener("abort", onAbort, { once: true });
|
|
627
|
+
}
|
|
496
628
|
|
|
497
629
|
ws.onopen = () => {
|
|
498
630
|
if (settled) return;
|
|
499
631
|
settled = true;
|
|
500
|
-
|
|
632
|
+
finalize();
|
|
501
633
|
this.#ws = ws;
|
|
502
634
|
resolve();
|
|
503
635
|
};
|
|
@@ -506,7 +638,7 @@ export class PythonKernel {
|
|
|
506
638
|
const error = new Error(`WebSocket error: ${event}`);
|
|
507
639
|
if (!settled) {
|
|
508
640
|
settled = true;
|
|
509
|
-
|
|
641
|
+
finalize();
|
|
510
642
|
reject(error);
|
|
511
643
|
return;
|
|
512
644
|
}
|
|
@@ -520,7 +652,7 @@ export class PythonKernel {
|
|
|
520
652
|
this.#ws = null;
|
|
521
653
|
if (!settled) {
|
|
522
654
|
settled = true;
|
|
523
|
-
|
|
655
|
+
finalize();
|
|
524
656
|
reject(new Error("WebSocket closed before connection"));
|
|
525
657
|
return;
|
|
526
658
|
}
|
|
@@ -561,7 +693,11 @@ export class PythonKernel {
|
|
|
561
693
|
return promise;
|
|
562
694
|
}
|
|
563
695
|
|
|
564
|
-
async #initializeKernelEnvironment(
|
|
696
|
+
async #initializeKernelEnvironment(
|
|
697
|
+
cwd: string,
|
|
698
|
+
env?: Record<string, string | undefined>,
|
|
699
|
+
options: KernelLifecycleOptions = {},
|
|
700
|
+
): Promise<void> {
|
|
565
701
|
const envEntries = Object.entries(env ?? {}).filter(([, value]) => value !== undefined);
|
|
566
702
|
const envPayload = Object.fromEntries(envEntries);
|
|
567
703
|
const initScript = [
|
|
@@ -572,10 +708,13 @@ export class PythonKernel {
|
|
|
572
708
|
"for __omp_key, __omp_val in __omp_env.items():\n os.environ[__omp_key] = __omp_val",
|
|
573
709
|
"if __omp_cwd not in sys.path:\n sys.path.insert(0, __omp_cwd)",
|
|
574
710
|
].join("\n");
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
711
|
+
const executeOptions = getStartupExecuteOptions(options);
|
|
712
|
+
const result = await this.execute(initScript, {
|
|
713
|
+
...executeOptions,
|
|
714
|
+
silent: true,
|
|
715
|
+
storeHistory: false,
|
|
716
|
+
});
|
|
717
|
+
throwIfStartupExecutionFailed(result, executeOptions.signal, "Failed to initialize Python kernel environment");
|
|
579
718
|
}
|
|
580
719
|
|
|
581
720
|
#abortPendingExecutions(reason: string): void {
|
|
@@ -842,16 +981,23 @@ export class PythonKernel {
|
|
|
842
981
|
}
|
|
843
982
|
}
|
|
844
983
|
|
|
845
|
-
async shutdown(): Promise<void> {
|
|
984
|
+
async shutdown(options?: KernelShutdownOptions): Promise<void> {
|
|
846
985
|
if (this.#disposed) return;
|
|
847
986
|
this.#disposed = true;
|
|
848
987
|
this.#alive = false;
|
|
849
988
|
this.#abortPendingExecutions("Kernel shutdown");
|
|
850
989
|
|
|
990
|
+
const shutdownSignal = combineAbortSignal(
|
|
991
|
+
{ signal: options?.signal },
|
|
992
|
+
options?.timeoutMs,
|
|
993
|
+
"Python kernel shutdown timed out",
|
|
994
|
+
);
|
|
995
|
+
|
|
851
996
|
try {
|
|
852
997
|
await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}`, {
|
|
853
998
|
method: "DELETE",
|
|
854
999
|
headers: this.#authHeaders(),
|
|
1000
|
+
signal: shutdownSignal,
|
|
855
1001
|
});
|
|
856
1002
|
} catch (err: unknown) {
|
|
857
1003
|
logger.warn("Failed to delete kernel via API", { error: err instanceof Error ? err.message : String(err) });
|
package/src/ipy/modules.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { getAgentModulesDir, getProjectDir, getProjectModulesDir } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { getExecutionCancellationError } from "./cancellation";
|
|
4
5
|
|
|
5
6
|
export type PythonModuleSource = "user" | "project";
|
|
6
7
|
|
|
@@ -13,13 +14,14 @@ export interface PythonModuleEntry {
|
|
|
13
14
|
export interface PythonModuleExecuteResult {
|
|
14
15
|
status: "ok" | "error";
|
|
15
16
|
cancelled: boolean;
|
|
17
|
+
timedOut?: boolean;
|
|
16
18
|
error?: { name: string; value: string; traceback: string[] };
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface PythonModuleExecutor {
|
|
20
22
|
execute: (
|
|
21
23
|
code: string,
|
|
22
|
-
options?: { silent?: boolean; storeHistory?: boolean },
|
|
24
|
+
options?: { signal?: AbortSignal; timeoutMs?: number; silent?: boolean; storeHistory?: boolean },
|
|
23
25
|
) => Promise<PythonModuleExecuteResult>;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -30,6 +32,12 @@ export interface DiscoverPythonModulesOptions {
|
|
|
30
32
|
agentDir?: string;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
export interface LoadPythonModulesOptions extends DiscoverPythonModulesOptions {
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
deadlineMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
interface ModuleCandidate {
|
|
34
42
|
name: string;
|
|
35
43
|
path: string;
|
|
@@ -61,6 +69,25 @@ async function readModuleContent(candidate: ModuleCandidate): Promise<PythonModu
|
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
function createTimeoutError(message: string): Error {
|
|
73
|
+
const error = new Error(message);
|
|
74
|
+
error.name = "TimeoutError";
|
|
75
|
+
return error;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function requireModuleExecutionTimeoutMs(options: LoadPythonModulesOptions): number | undefined {
|
|
79
|
+
if (options.deadlineMs === undefined) {
|
|
80
|
+
return options.timeoutMs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const remainingMs = options.deadlineMs - Date.now();
|
|
84
|
+
if (remainingMs <= 0) {
|
|
85
|
+
throw createTimeoutError("Python module loading timed out");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return remainingMs;
|
|
89
|
+
}
|
|
90
|
+
|
|
64
91
|
/**
|
|
65
92
|
* Discover Python prelude extension modules from user and project directories.
|
|
66
93
|
*/
|
|
@@ -95,12 +122,20 @@ export async function discoverPythonModules(options: DiscoverPythonModulesOption
|
|
|
95
122
|
*/
|
|
96
123
|
export async function loadPythonModules(
|
|
97
124
|
executor: PythonModuleExecutor,
|
|
98
|
-
options:
|
|
125
|
+
options: LoadPythonModulesOptions = {},
|
|
99
126
|
): Promise<PythonModuleEntry[]> {
|
|
100
127
|
const modules = await discoverPythonModules(options);
|
|
101
128
|
for (const module of modules) {
|
|
102
|
-
const result = await executor.execute(module.content, {
|
|
103
|
-
|
|
129
|
+
const result = await executor.execute(module.content, {
|
|
130
|
+
signal: options.signal,
|
|
131
|
+
timeoutMs: requireModuleExecutionTimeoutMs(options),
|
|
132
|
+
silent: true,
|
|
133
|
+
storeHistory: false,
|
|
134
|
+
});
|
|
135
|
+
if (result.cancelled) {
|
|
136
|
+
throw getExecutionCancellationError(result, options.signal, `Failed to load Python module ${module.path}`);
|
|
137
|
+
}
|
|
138
|
+
if (result.status === "error") {
|
|
104
139
|
const details = result.error ? `${result.error.name}: ${result.error.value}` : "unknown error";
|
|
105
140
|
throw new Error(`Failed to load Python module ${module.path}: ${details}`);
|
|
106
141
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Multi-line editor component for hooks.
|
|
2
|
+
* Multi-line editor component for hooks and ask custom input.
|
|
3
3
|
* Supports Ctrl+G for external editor.
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* - Default (hook): Enter inserts newline, Ctrl+Enter submits, bordered popup
|
|
7
|
+
* - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome
|
|
4
8
|
*/
|
|
5
9
|
import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
|
6
10
|
import { getEditorTheme, theme } from "../../modes/theme/theme";
|
|
@@ -8,11 +12,17 @@ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
|
|
8
12
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
9
13
|
import { DynamicBorder } from "./dynamic-border";
|
|
10
14
|
|
|
15
|
+
export interface HookEditorOptions {
|
|
16
|
+
/** When true, use prompt-style keybindings with the legacy ask prompt chrome. */
|
|
17
|
+
promptStyle?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
export class HookEditorComponent extends Container {
|
|
12
21
|
#editor: Editor;
|
|
13
22
|
#onSubmitCallback: (value: string) => void;
|
|
14
23
|
#onCancelCallback: () => void;
|
|
15
24
|
#tui: TUI;
|
|
25
|
+
#promptStyle: boolean;
|
|
16
26
|
|
|
17
27
|
constructor(
|
|
18
28
|
tui: TUI,
|
|
@@ -20,23 +30,29 @@ export class HookEditorComponent extends Container {
|
|
|
20
30
|
prefill: string | undefined,
|
|
21
31
|
onSubmit: (value: string) => void,
|
|
22
32
|
onCancel: () => void,
|
|
33
|
+
options?: HookEditorOptions,
|
|
23
34
|
) {
|
|
24
35
|
super();
|
|
25
36
|
|
|
26
37
|
this.#tui = tui;
|
|
27
38
|
this.#onSubmitCallback = onSubmit;
|
|
28
39
|
this.#onCancelCallback = onCancel;
|
|
40
|
+
this.#promptStyle = options?.promptStyle ?? false;
|
|
29
41
|
|
|
30
|
-
// Add top border
|
|
31
42
|
this.addChild(new DynamicBorder());
|
|
32
43
|
this.addChild(new Spacer(1));
|
|
33
44
|
|
|
34
|
-
//
|
|
45
|
+
// Title
|
|
35
46
|
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
|
36
47
|
this.addChild(new Spacer(1));
|
|
37
48
|
|
|
38
|
-
//
|
|
49
|
+
// Editor
|
|
39
50
|
this.#editor = new Editor(getEditorTheme());
|
|
51
|
+
if (this.#promptStyle) {
|
|
52
|
+
this.#editor.setBorderVisible(false);
|
|
53
|
+
this.#editor.setPromptGutter("> ");
|
|
54
|
+
this.#editor.disableSubmit = true;
|
|
55
|
+
}
|
|
40
56
|
if (prefill) {
|
|
41
57
|
this.#editor.setText(prefill);
|
|
42
58
|
}
|
|
@@ -44,17 +60,50 @@ export class HookEditorComponent extends Container {
|
|
|
44
60
|
|
|
45
61
|
this.addChild(new Spacer(1));
|
|
46
62
|
|
|
47
|
-
//
|
|
48
|
-
const hint =
|
|
63
|
+
// Hint
|
|
64
|
+
const hint = this.#promptStyle
|
|
65
|
+
? "enter submit esc cancel ctrl+g external editor"
|
|
66
|
+
: "ctrl+enter submit esc cancel ctrl+g external editor";
|
|
49
67
|
this.addChild(new Text(theme.fg("dim", hint), 1, 0));
|
|
50
68
|
|
|
51
69
|
this.addChild(new Spacer(1));
|
|
52
|
-
|
|
53
|
-
// Add bottom border
|
|
54
70
|
this.addChild(new DynamicBorder());
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
handleInput(keyData: string): void {
|
|
74
|
+
if (this.#promptStyle) {
|
|
75
|
+
this.#handlePromptStyleInput(keyData);
|
|
76
|
+
} else {
|
|
77
|
+
this.#handleHookStyleInput(keyData);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Prompt-style: raw Enter submits; Editor owns newline-producing sequences. */
|
|
82
|
+
#handlePromptStyleInput(keyData: string): void {
|
|
83
|
+
// Prompt-style keeps Escape as an explicit cancel key and also honors app.interrupt remaps.
|
|
84
|
+
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesAppInterrupt(keyData)) {
|
|
85
|
+
this.#onCancelCallback();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Ctrl+G for external editor
|
|
90
|
+
if (matchesKey(keyData, "ctrl+g")) {
|
|
91
|
+
void this.#openExternalEditor();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Submit on any plain Enter encoding, including terminals that report unmodified Enter as LF.
|
|
96
|
+
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
|
|
97
|
+
this.#onSubmitCallback(this.#editor.getText());
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Let Editor handle modified newline-producing variants (Shift+Enter, Ctrl+Enter, Alt+Enter, etc.)
|
|
102
|
+
this.#editor.handleInput(keyData);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Hook-style: Enter=newline, Ctrl+Enter=submit (original behavior) */
|
|
106
|
+
#handleHookStyleInput(keyData: string): void {
|
|
58
107
|
// Ctrl+Enter to submit
|
|
59
108
|
if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") {
|
|
60
109
|
this.#onSubmitCallback(this.#editor.getText());
|