@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +30 -16
  2. package/package.json +7 -7
  3. package/src/commit/agentic/tools/analyze-file.ts +1 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/custom-tools/types.ts +3 -0
  7. package/src/extensibility/extensions/runner.ts +7 -0
  8. package/src/extensibility/extensions/types.ts +10 -1
  9. package/src/extensibility/hooks/types.ts +1 -1
  10. package/src/internal-urls/docs-index.generated.ts +1 -1
  11. package/src/ipy/cancellation.ts +28 -0
  12. package/src/ipy/executor.ts +252 -77
  13. package/src/ipy/kernel.ts +181 -35
  14. package/src/ipy/modules.ts +39 -4
  15. package/src/modes/acp/acp-agent.ts +1 -0
  16. package/src/modes/components/hook-editor.ts +57 -8
  17. package/src/modes/components/model-selector.ts +48 -29
  18. package/src/modes/components/settings-defs.ts +10 -1
  19. package/src/modes/components/settings-selector.ts +92 -5
  20. package/src/modes/controllers/extension-ui-controller.ts +35 -4
  21. package/src/modes/controllers/input-controller.ts +4 -3
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +7 -2
  24. package/src/modes/print-mode.ts +1 -0
  25. package/src/modes/prompt-action-autocomplete.ts +5 -3
  26. package/src/modes/rpc/rpc-mode.ts +79 -30
  27. package/src/modes/rpc/rpc-types.ts +9 -1
  28. package/src/modes/theme/theme.ts +70 -0
  29. package/src/modes/types.ts +6 -1
  30. package/src/prompts/system/custom-system-prompt.md +5 -0
  31. package/src/prompts/system/system-prompt.md +6 -0
  32. package/src/prompts/tools/ask.md +1 -0
  33. package/src/prompts/tools/grep.md +1 -1
  34. package/src/prompts/tools/hashline.md +20 -5
  35. package/src/sdk.ts +26 -2
  36. package/src/session/agent-session.ts +18 -11
  37. package/src/system-prompt.ts +63 -2
  38. package/src/task/executor.ts +4 -0
  39. package/src/task/index.ts +2 -0
  40. package/src/tools/ask.ts +109 -61
  41. package/src/tools/ast-edit.ts +2 -16
  42. package/src/tools/ast-grep.ts +2 -17
  43. package/src/tools/browser.ts +35 -17
  44. package/src/tools/find.ts +1 -0
  45. package/src/tools/grep.ts +25 -34
  46. package/src/tools/index.ts +3 -0
  47. package/src/tools/path-utils.ts +7 -0
  48. package/src/tools/python.ts +3 -2
  49. package/src/tools/render-utils.ts +27 -0
  50. 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 preludeResult = await kernel.execute(PYTHON_PRELUDE, { silent: true, storeHistory: false });
418
- if (preludeResult.cancelled || preludeResult.status === "error") {
419
- throw new Error("Failed to initialize Python kernel prelude");
420
- }
421
- await loadPythonModules(kernel, { cwd });
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", () => kernel.#initializeKernelEnvironment(cwd, env));
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, { silent: true, storeHistory: false }),
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 timeout = setTimeout(() => {
490
- ws.close();
491
- if (!settled) {
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
- }, 10000);
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
- clearTimeout(timeout);
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
- clearTimeout(timeout);
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
- clearTimeout(timeout);
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(cwd: string, env?: Record<string, string | undefined>): Promise<void> {
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 result = await this.execute(initScript, { silent: true, storeHistory: false });
576
- if (result.cancelled || result.status === "error") {
577
- throw new Error("Failed to initialize Python kernel environment");
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) });
@@ -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: DiscoverPythonModulesOptions = {},
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, { silent: true, storeHistory: false });
103
- if (result.cancelled || result.status === "error") {
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
  }
@@ -832,6 +832,7 @@ export class AcpAgent implements Agent {
832
832
  },
833
833
  {
834
834
  getModel: () => this.#session.model,
835
+ getSearchDb: () => this.#session.searchDb,
835
836
  isIdle: () => !this.#session.isStreaming,
836
837
  abort: () => {
837
838
  void this.#session.abort();
@@ -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
- // Add title
45
+ // Title
35
46
  this.addChild(new Text(theme.fg("accent", title), 1, 0));
36
47
  this.addChild(new Spacer(1));
37
48
 
38
- // Create editor
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
- // Add hint
48
- const hint = "ctrl+enter submit esc cancel ctrl+g external editor";
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());