@oh-my-pi/pi-coding-agent 15.7.5 → 15.7.6

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 (74) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/types/config/settings-schema.d.ts +9 -0
  3. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  4. package/dist/types/eval/heartbeat.d.ts +45 -0
  5. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  7. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  8. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  9. package/dist/types/lsp/index.d.ts +2 -0
  10. package/dist/types/lsp/utils.d.ts +4 -0
  11. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  12. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  13. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  14. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -2
  17. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  18. package/dist/types/modes/theme/theme.d.ts +1 -1
  19. package/dist/types/modes/types.d.ts +2 -2
  20. package/dist/types/session/agent-session.d.ts +8 -1
  21. package/dist/types/tools/ask.d.ts +8 -6
  22. package/dist/types/tools/eval-backends.d.ts +12 -0
  23. package/dist/types/tools/eval-render.d.ts +52 -0
  24. package/dist/types/tools/eval.d.ts +2 -35
  25. package/dist/types/tools/index.d.ts +4 -11
  26. package/dist/types/tui/output-block.d.ts +4 -3
  27. package/examples/extensions/README.md +1 -0
  28. package/examples/extensions/thinking-note.ts +13 -0
  29. package/package.json +9 -9
  30. package/src/config/model-registry.ts +33 -4
  31. package/src/config/settings-schema.ts +10 -0
  32. package/src/discovery/claude.ts +41 -22
  33. package/src/edit/index.ts +23 -3
  34. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  35. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  36. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  37. package/src/eval/agent-bridge.ts +44 -38
  38. package/src/eval/heartbeat.ts +74 -0
  39. package/src/eval/js/executor.ts +13 -9
  40. package/src/eval/llm-bridge.ts +20 -14
  41. package/src/eval/py/executor.ts +14 -18
  42. package/src/exec/bash-executor.ts +31 -5
  43. package/src/extensibility/extensions/loader.ts +16 -18
  44. package/src/extensibility/extensions/runner.ts +22 -17
  45. package/src/extensibility/extensions/types.ts +39 -5
  46. package/src/internal-urls/docs-index.generated.ts +3 -3
  47. package/src/lsp/diagnostics-ledger.ts +51 -0
  48. package/src/lsp/index.ts +9 -22
  49. package/src/lsp/utils.ts +21 -0
  50. package/src/modes/acp/acp-agent.ts +8 -4
  51. package/src/modes/components/assistant-message.ts +28 -1
  52. package/src/modes/components/custom-editor.ts +9 -7
  53. package/src/modes/components/hook-selector.ts +159 -32
  54. package/src/modes/components/tool-execution.ts +20 -4
  55. package/src/modes/controllers/event-controller.ts +5 -2
  56. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  57. package/src/modes/controllers/input-controller.ts +0 -15
  58. package/src/modes/interactive-mode.ts +2 -1
  59. package/src/modes/rpc/rpc-mode.ts +17 -6
  60. package/src/modes/theme/theme-schema.json +30 -0
  61. package/src/modes/theme/theme.ts +39 -2
  62. package/src/modes/types.ts +2 -1
  63. package/src/modes/utils/ui-helpers.ts +5 -2
  64. package/src/prompts/tools/ask.md +2 -1
  65. package/src/prompts/tools/eval.md +1 -1
  66. package/src/session/agent-session.ts +65 -11
  67. package/src/tools/ask.ts +74 -32
  68. package/src/tools/eval-backends.ts +38 -0
  69. package/src/tools/eval-render.ts +750 -0
  70. package/src/tools/eval.ts +27 -754
  71. package/src/tools/index.ts +7 -37
  72. package/src/tools/renderers.ts +1 -1
  73. package/src/tools/write.ts +9 -1
  74. package/src/tui/output-block.ts +5 -4
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Keepalive for in-flight host-side eval bridge calls.
3
+ *
4
+ * The eval watchdog ({@link ../tools/eval IdleTimeout}) caps a cell's `timeout`
5
+ * as a wall-clock budget on the cell's *own* work, but pauses that budget while
6
+ * a host-side `agent()`/`parallel()` (via `runSubprocess`) or `llm()` (a single
7
+ * completion) call is in flight. Those calls are the only thing that re-arms the
8
+ * watchdog — and they can run for long stretches with **no** status of their own
9
+ * (a subagent's time-to-first-token on a reasoning model, a long quiet nested
10
+ * tool, or the entire body of a oneshot `llm()` call). Without a keepalive the
11
+ * watchdog would mistake that delegated work for the cell stalling and abort it
12
+ * mid-flight, killing the subagent.
13
+ *
14
+ * {@link withBridgeHeartbeat} bridges that gap by emitting a synthetic
15
+ * {@link EVAL_HEARTBEAT_OP} status event immediately when the call begins and
16
+ * then on a fixed cadence until it settles. The event rides the same
17
+ * `emitStatus → onStatus` channel both runtimes already forward, so it re-arms
18
+ * the watchdog without any new plumbing. The heartbeat is the *sole* signal that
19
+ * extends the budget: consumers MUST treat it as a pure keepalive — bump the
20
+ * watchdog and drop it (never persist or render it) — see the executor display
21
+ * sinks and the eval tool's `onStatus` handler. Every other status event
22
+ * (compute helpers, `log()`/`phase()`, tool results) counts against the budget.
23
+ */
24
+ import type { JsStatusEvent } from "./js/shared/types";
25
+
26
+ /**
27
+ * Synthetic status op emitted purely to keep the eval idle watchdog alive while
28
+ * a host-side bridge call is in flight. Carries no payload.
29
+ */
30
+ export const EVAL_HEARTBEAT_OP = "heartbeat";
31
+
32
+ /**
33
+ * Heartbeat cadence. Comfortably below the default 30s idle budget (and the
34
+ * larger budgets long fanouts run under), so a working bridge call always bumps
35
+ * the watchdog before it expires, while a genuine stall is still bounded once
36
+ * the call settles and the heartbeat stops.
37
+ */
38
+ const HEARTBEAT_INTERVAL_MS = 5_000;
39
+
40
+ let heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS;
41
+
42
+ /**
43
+ * Test seam: override the heartbeat cadence so integration tests can exercise
44
+ * the keepalive within a sub-second idle budget. Pass no value to restore the
45
+ * production default.
46
+ */
47
+ export function setBridgeHeartbeatIntervalMs(ms?: number): void {
48
+ heartbeatIntervalMs = ms === undefined ? HEARTBEAT_INTERVAL_MS : Math.max(1, Math.floor(ms));
49
+ }
50
+
51
+ /**
52
+ * Run {@link operation}, pumping {@link EVAL_HEARTBEAT_OP} status events through
53
+ * {@link emitStatus} — one immediately, then on a fixed cadence — until it
54
+ * settles. The immediate beat pauses the watchdog the instant the call begins,
55
+ * so a bridge call that starts close to the budget edge (after the cell already
56
+ * spent most of it computing) is not aborted before the first interval tick. A
57
+ * no-op wrapper when no `emitStatus` sink is wired (the heartbeat would reach
58
+ * nobody).
59
+ */
60
+ export async function withBridgeHeartbeat<T>(
61
+ emitStatus: ((event: JsStatusEvent) => void) | undefined,
62
+ operation: () => Promise<T>,
63
+ ): Promise<T> {
64
+ if (!emitStatus) return operation();
65
+ emitStatus({ op: EVAL_HEARTBEAT_OP });
66
+ const timer = setInterval(() => emitStatus({ op: EVAL_HEARTBEAT_OP }), heartbeatIntervalMs);
67
+ // Never keep the event loop alive for the heartbeat alone.
68
+ timer.unref?.();
69
+ try {
70
+ return await operation();
71
+ } finally {
72
+ clearInterval(timer);
73
+ }
74
+ }
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
2
2
  import type { ToolSession } from "../../tools";
3
3
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
4
+ import { EVAL_HEARTBEAT_OP } from "../heartbeat";
4
5
  import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
5
6
  import type { JsStatusEvent } from "./shared/types";
6
7
 
@@ -59,11 +60,10 @@ function isTimeoutReason(reason: unknown): boolean {
59
60
  );
60
61
  }
61
62
 
62
- function formatJsTimeoutAnnotation(timeoutMs: number | undefined, idle: boolean): string {
63
- const suffix = idle ? " of inactivity" : "";
63
+ function formatJsTimeoutAnnotation(timeoutMs: number | undefined): string {
64
64
  if (timeoutMs === undefined) return "Command timed out";
65
65
  const secs = Math.max(1, Math.round(timeoutMs / 1000));
66
- return `Command timed out after ${secs} seconds${suffix}`;
66
+ return `Command timed out after ${secs} seconds`;
67
67
  }
68
68
 
69
69
  export async function executeJs(code: string, options: JsExecutorOptions): Promise<JsResult> {
@@ -85,10 +85,9 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
85
85
  options.signal && timeoutSignal
86
86
  ? AbortSignal.any([options.signal, timeoutSignal])
87
87
  : (options.signal ?? timeoutSignal);
88
- // Idle mode: the eval tool drives cancellation via an idle-aware `signal` and
89
- // passes only an inactivity budget. Use it for worker cold-start headroom and
90
- // timeout-annotation text; never derive a competing fixed timer from it.
91
- const idleMode = legacyTimeoutMs === undefined && options.idleTimeoutMs !== undefined;
88
+ // The eval tool drives cancellation via an idle-aware `signal` and passes only
89
+ // an inactivity budget; use it solely as worker cold-start headroom and never
90
+ // derive a competing fixed timer from it.
92
91
  const acquireBudgetMs = legacyTimeoutMs ?? options.idleTimeoutMs;
93
92
 
94
93
  try {
@@ -105,8 +104,13 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
105
104
  signal,
106
105
  onText: chunk => outputSink.push(chunk),
107
106
  onDisplay: output => {
107
+ if (output.type === "status") {
108
+ // Heartbeats are pure idle-watchdog keepalives: forward them so
109
+ // the eval tool re-arms its timer, but never store or render them.
110
+ options.onStatus?.(output.event);
111
+ if (output.event.op === EVAL_HEARTBEAT_OP) return;
112
+ }
108
113
  displayOutputs.push(output);
109
- if (output.type === "status") options.onStatus?.(output.event);
110
114
  },
111
115
  },
112
116
  });
@@ -127,7 +131,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
127
131
  if (signal?.aborted || isAbortError(error)) {
128
132
  const timedOut = Boolean(timeoutSignal?.aborted) || isTimeoutReason(options.signal?.reason);
129
133
  if (timedOut) {
130
- outputSink.push(formatJsTimeoutAnnotation(legacyTimeoutMs ?? options.idleTimeoutMs, idleMode));
134
+ outputSink.push(formatJsTimeoutAnnotation(legacyTimeoutMs ?? options.idleTimeoutMs));
131
135
  }
132
136
  const summary = await outputSink.dump();
133
137
  return {
@@ -18,6 +18,7 @@ import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit
18
18
  import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver";
19
19
  import type { ToolSession } from "../tools";
20
20
  import { ToolError } from "../tools/tool-errors";
21
+ import { withBridgeHeartbeat } from "./heartbeat";
21
22
  import type { JsStatusEvent } from "./js/shared/types";
22
23
 
23
24
  /** Synthetic bridge name reserved for the `llm()` helper across both runtimes. */
@@ -131,20 +132,25 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
131
132
 
132
133
  const telemetry = resolveTelemetry(options.session.getTelemetry?.(), options.session.getSessionId?.() ?? undefined);
133
134
 
134
- const response = await instrumentedCompleteSimple(
135
- model,
136
- {
137
- systemPrompt: system ? [system] : undefined,
138
- messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
139
- tools,
140
- },
141
- {
142
- apiKey,
143
- signal: options.signal,
144
- reasoning: reasoningForTier(tier, model),
145
- toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined,
146
- },
147
- { telemetry, oneshotKind: "eval_llm" },
135
+ // A oneshot completion emits no status until it returns, so pump a heartbeat
136
+ // while it runs to keep the eval idle watchdog armed across a slow (e.g.
137
+ // reasoning-tier) request that would otherwise look like a stalled cell.
138
+ const response = await withBridgeHeartbeat(options.emitStatus, () =>
139
+ instrumentedCompleteSimple(
140
+ model,
141
+ {
142
+ systemPrompt: system ? [system] : undefined,
143
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
144
+ tools,
145
+ },
146
+ {
147
+ apiKey,
148
+ signal: options.signal,
149
+ reasoning: reasoningForTier(tier, model),
150
+ toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined,
151
+ },
152
+ { telemetry, oneshotKind: "eval_llm" },
153
+ ),
148
154
  );
149
155
 
150
156
  if (response.stopReason === "error") {
@@ -5,6 +5,7 @@ import { Settings } from "../../config/settings";
5
5
  import { OutputSink } from "../../session/streaming-output";
6
6
  import type { ToolSession } from "../../tools";
7
7
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
8
+ import { EVAL_HEARTBEAT_OP } from "../heartbeat";
8
9
  import type { JsStatusEvent } from "../js/shared/types";
9
10
  import {
10
11
  checkPythonKernelAvailability,
@@ -231,20 +232,18 @@ async function waitForPromiseWithCancellation<T>(
231
232
  // Result formatting
232
233
  // ---------------------------------------------------------------------------
233
234
 
234
- function formatTimeoutAnnotation(timeoutMs?: number, idle = false): string | undefined {
235
- const suffix = idle ? " of inactivity" : "";
235
+ function formatTimeoutAnnotation(timeoutMs?: number): string | undefined {
236
236
  if (timeoutMs === undefined) return "Command timed out";
237
237
  const secs = Math.max(1, Math.round(timeoutMs / 1000));
238
- return `Command timed out after ${secs} seconds${suffix}`;
238
+ return `Command timed out after ${secs} seconds`;
239
239
  }
240
240
 
241
- function formatKernelTimeoutAnnotation(timeoutMs: number | undefined, kernelKilled: boolean, idle = false): string {
241
+ function formatKernelTimeoutAnnotation(timeoutMs: number | undefined, kernelKilled: boolean): string {
242
242
  const secs = timeoutMs === undefined ? undefined : Math.max(1, Math.round(timeoutMs / 1000));
243
- const suffix = idle ? " of inactivity" : "";
244
243
  if (kernelKilled) {
245
- return `eval cell timed out${suffix} and the kernel was unresponsive to interrupt; the kernel has been killed and will be recreated on the next call.`;
244
+ return "eval cell timed out and the kernel was unresponsive to interrupt; the kernel has been killed and will be recreated on the next call.";
246
245
  }
247
- const duration = secs === undefined ? "the configured timeout" : `${secs}s${suffix}`;
246
+ const duration = secs === undefined ? "the configured timeout" : `${secs}s`;
248
247
  return `eval cell timed out after ${duration}; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted.`;
249
248
  }
250
249
 
@@ -488,16 +487,17 @@ async function executeWithKernel(
488
487
  const displayOutputs: KernelDisplayOutput[] = [];
489
488
  const deadlineMs = getExecutionDeadlineMs(options);
490
489
  let executionTimeoutMs: number | undefined;
491
- // Idle mode: the caller (eval tool) drives cancellation via an idle-aware
492
- // signal and passes no wall-clock deadline, so annotate timeouts with the
493
- // configured inactivity budget rather than a remaining-deadline figure.
494
- const idleMode = deadlineMs === undefined && options?.idleTimeoutMs !== undefined;
495
490
 
496
491
  // Collect every display output and, for status events, stream them live so
497
492
  // long-running bridge helpers (e.g. `agent()`) surface progress mid-cell.
498
493
  const collectDisplay = (output: KernelDisplayOutput) => {
494
+ if (output.type === "status") {
495
+ // Heartbeats are pure idle-watchdog keepalives: forward them so the
496
+ // eval tool re-arms its timer, but never store or render them.
497
+ options?.onStatus?.(output.event);
498
+ if (output.event.op === EVAL_HEARTBEAT_OP) return;
499
+ }
499
500
  displayOutputs.push(output);
500
- if (output.type === "status") options?.onStatus?.(output.event);
501
501
  };
502
502
  const emitStatus = options?.emitStatus ?? ((event: JsStatusEvent) => collectDisplay({ type: "status", event }));
503
503
  const runId = `py-${crypto.randomUUID()}`;
@@ -524,11 +524,7 @@ async function executeWithKernel(
524
524
 
525
525
  if (result.cancelled) {
526
526
  const annotation = result.timedOut
527
- ? formatKernelTimeoutAnnotation(
528
- executionTimeoutMs ?? options?.idleTimeoutMs,
529
- result.kernelKilled ?? false,
530
- idleMode,
531
- )
527
+ ? formatKernelTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs, result.kernelKilled ?? false)
532
528
  : undefined;
533
529
  return {
534
530
  exitCode: undefined,
@@ -566,7 +562,7 @@ async function executeWithKernel(
566
562
  displayOutputs,
567
563
  stdinRequested: false,
568
564
  ...(await sink.dump(
569
- timedOut ? formatTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs, idleMode) : undefined,
565
+ timedOut ? formatTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs) : undefined,
570
566
  )),
571
567
  };
572
568
  }
@@ -52,6 +52,27 @@ export interface BashResult {
52
52
 
53
53
  const shellSessions = new Map<string, Shell>();
54
54
  const brokenShellSessions = new Set<string>();
55
+ const shellSessionQuarantines = new Map<string, Promise<unknown>>();
56
+
57
+ function quarantineShellSession(
58
+ sessionKey: string,
59
+ runPromise: Promise<ShellRunResult>,
60
+ abortCleanupPromise: Promise<void> | undefined,
61
+ ): void {
62
+ brokenShellSessions.add(sessionKey);
63
+ const cleanup = abortCleanupPromise
64
+ ? Promise.allSettled([runPromise, abortCleanupPromise])
65
+ : Promise.allSettled([runPromise]);
66
+ shellSessionQuarantines.set(sessionKey, cleanup);
67
+ void cleanup
68
+ .finally(() => {
69
+ if (shellSessionQuarantines.get(sessionKey) === cleanup) {
70
+ shellSessionQuarantines.delete(sessionKey);
71
+ brokenShellSessions.delete(sessionKey);
72
+ }
73
+ })
74
+ .catch(() => undefined);
75
+ }
55
76
 
56
77
  async function resolveShellCwd(cwd: string | undefined): Promise<string | undefined> {
57
78
  if (!cwd) return undefined;
@@ -134,13 +155,13 @@ export async function executeBash(command: string, options?: BashExecutorOptions
134
155
  }
135
156
  const userSignal = options?.signal;
136
157
  const runAbortController = new AbortController();
158
+ let abortCleanupPromise: Promise<void> | undefined;
137
159
  const abortCurrentExecution = () => {
138
160
  if (!runAbortController.signal.aborted) {
139
161
  runAbortController.abort();
140
162
  }
141
- if (shellSession) {
142
- // Native abort is async; fire-and-forget because the caller races the command separately.
143
- void shellSession.abort();
163
+ if (shellSession && !abortCleanupPromise) {
164
+ abortCleanupPromise = shellSession.abort().catch(() => undefined);
144
165
  }
145
166
  };
146
167
  const abortDeferred = Promise.withResolvers<"abort">();
@@ -209,8 +230,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
209
230
  acceptingChunks = false;
210
231
  if (shellSession) {
211
232
  resetSession = true;
212
- brokenShellSessions.add(sessionKey);
213
- void runPromise.finally(() => brokenShellSessions.delete(sessionKey)).catch(() => undefined);
233
+ quarantineShellSession(sessionKey, runPromise, abortCleanupPromise);
214
234
  } else {
215
235
  void runPromise.catch(() => undefined);
216
236
  }
@@ -235,6 +255,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions
235
255
  ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
236
256
  : "Command timed out";
237
257
  resetSession = true;
258
+ if (shellSession) {
259
+ quarantineShellSession(sessionKey, runPromise, abortCleanupPromise);
260
+ }
238
261
  return {
239
262
  exitCode: undefined,
240
263
  cancelled: true,
@@ -245,6 +268,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions
245
268
  // Handle cancellation
246
269
  if (winner.result.cancelled) {
247
270
  resetSession = true;
271
+ if (shellSession) {
272
+ quarantineShellSession(sessionKey, runPromise, abortCleanupPromise);
273
+ }
248
274
  return {
249
275
  exitCode: undefined,
250
276
  cancelled: true,
@@ -5,7 +5,8 @@ import type * as fs1 from "node:fs";
5
5
  import * as fs from "node:fs/promises";
6
6
  import * as path from "node:path";
7
7
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
- import type { ImageContent, Model, TextContent } from "@oh-my-pi/pi-ai";
8
+ import type { ImageContent, Model, TextContent, TSchema } from "@oh-my-pi/pi-ai";
9
+ import * as PiCodingAgent from "@oh-my-pi/pi-coding-agent";
9
10
  import type { KeyId } from "@oh-my-pi/pi-tui";
10
11
  import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils";
11
12
  import * as Zod from "zod/v4";
@@ -22,6 +23,7 @@ import * as TypeBox from "../typebox";
22
23
 
23
24
  import { resolvePath } from "../utils";
24
25
  import type {
26
+ AssistantThinkingRenderer,
25
27
  Extension,
26
28
  ExtensionAPI,
27
29
  ExtensionContext,
@@ -29,6 +31,7 @@ import type {
29
31
  ExtensionRuntime as IExtensionRuntime,
30
32
  LoadExtensionsResult,
31
33
  MessageRenderer,
34
+ ProviderConfig,
32
35
  RegisteredCommand,
33
36
  ToolDefinition,
34
37
  } from "./types";
@@ -55,8 +58,7 @@ export class ExtensionRuntimeNotInitializedError extends Error {
55
58
  */
56
59
  export class ExtensionRuntime implements IExtensionRuntime {
57
60
  flagValues = new Map<string, boolean | string>();
58
- pendingProviderRegistrations: Array<{ name: string; config: import("./types").ProviderConfig; sourceId: string }> =
59
- [];
61
+ pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; sourceId: string }> = [];
60
62
 
61
63
  sendMessage(): void {
62
64
  throw new ExtensionRuntimeNotInitializedError();
@@ -123,12 +125,12 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
123
125
  readonly flagValues = new Map<string, boolean | string>();
124
126
  readonly pendingProviderRegistrations: Array<{
125
127
  name: string;
126
- config: import("./types").ProviderConfig;
128
+ config: ProviderConfig;
127
129
  sourceId: string;
128
130
  }> = [];
129
131
 
130
132
  constructor(
131
- public readonly pi: typeof import("@oh-my-pi/pi-coding-agent"),
133
+ public readonly pi: typeof PiCodingAgent,
132
134
  private readonly extension: Extension,
133
135
  private readonly runtime: IExtensionRuntime,
134
136
  private readonly cwd: string,
@@ -141,10 +143,7 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
141
143
  this.extension.handlers.set(event, list);
142
144
  }
143
145
 
144
- registerTool<
145
- TParams extends import("@oh-my-pi/pi-ai").TSchema = import("@oh-my-pi/pi-ai").TSchema,
146
- TDetails = unknown,
147
- >(tool: ToolDefinition<TParams, TDetails>): void {
146
+ registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void {
148
147
  this.extension.tools.set(tool.name, {
149
148
  definition: tool,
150
149
  extensionPath: this.extension.path,
@@ -190,6 +189,10 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
190
189
  this.extension.messageRenderers.set(customType, renderer as MessageRenderer);
191
190
  }
192
191
 
192
+ registerAssistantThinkingRenderer(renderer: AssistantThinkingRenderer): void {
193
+ this.extension.assistantThinkingRenderers.push(renderer);
194
+ }
195
+
193
196
  getFlag(name: string): boolean | string | undefined {
194
197
  if (!this.extension.flags.has(name)) return undefined;
195
198
  return this.runtime.flagValues.get(name);
@@ -253,7 +256,7 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
253
256
  return this.runtime.setSessionName(name);
254
257
  }
255
258
 
256
- registerProvider(name: string, config: import("./types").ProviderConfig): void {
259
+ registerProvider(name: string, config: ProviderConfig): void {
257
260
  this.runtime.pendingProviderRegistrations.push({ name, config, sourceId: this.extension.path });
258
261
  }
259
262
  }
@@ -267,6 +270,7 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension
267
270
  resolvedPath,
268
271
  handlers: new Map(),
269
272
  tools: new Map(),
273
+ assistantThinkingRenderers: [],
270
274
  messageRenderers: new Map(),
271
275
  commands: new Map(),
272
276
  flags: new Map(),
@@ -293,13 +297,7 @@ async function loadExtension(
293
297
  }
294
298
 
295
299
  const extension = createExtension(extensionPath, resolvedPath);
296
- const api = new ConcreteExtensionAPI(
297
- await import("@oh-my-pi/pi-coding-agent"),
298
- extension,
299
- runtime,
300
- cwd,
301
- eventBus,
302
- );
300
+ const api = new ConcreteExtensionAPI(PiCodingAgent, extension, runtime, cwd, eventBus);
303
301
  await factory(api);
304
302
 
305
303
  return { extension, error: null };
@@ -320,7 +318,7 @@ export async function loadExtensionFromFactory(
320
318
  name = "<inline>",
321
319
  ): Promise<Extension> {
322
320
  const extension = createExtension(name, name);
323
- const api = new ConcreteExtensionAPI(await import("@oh-my-pi/pi-coding-agent"), extension, runtime, cwd, eventBus);
321
+ const api = new ConcreteExtensionAPI(PiCodingAgent, extension, runtime, cwd, eventBus);
324
322
  await factory(api);
325
323
  return extension;
326
324
  }
@@ -10,6 +10,7 @@ import { type Theme, theme } from "../../modes/theme/theme";
10
10
  import type { SessionManager } from "../../session/session-manager";
11
11
  import type {
12
12
  AfterProviderResponseEvent,
13
+ AssistantThinkingRenderer,
13
14
  BeforeAgentStartEvent,
14
15
  BeforeAgentStartEventResult,
15
16
  BeforeProviderRequestEvent,
@@ -343,22 +344,22 @@ export class ExtensionRunner {
343
344
  this.runtime.flagValues.set(name, value);
344
345
  }
345
346
 
346
- static readonly #RESERVED_SHORTCUTS = new Set([
347
- "ctrl+c",
348
- "ctrl+d",
349
- "ctrl+z",
350
- "ctrl+k",
351
- "ctrl+p",
352
- "ctrl+l",
353
- "ctrl+o",
354
- "ctrl+t",
355
- "ctrl+g",
356
- "shift+tab",
357
- "shift+ctrl+p",
358
- "alt+enter",
359
- "escape",
360
- "enter",
361
- ]);
347
+ static readonly #RESERVED_SHORTCUTS: Record<string, true> = {
348
+ "ctrl+c": true,
349
+ "ctrl+d": true,
350
+ "ctrl+z": true,
351
+ "ctrl+k": true,
352
+ "ctrl+p": true,
353
+ "ctrl+l": true,
354
+ "ctrl+o": true,
355
+ "ctrl+t": true,
356
+ "ctrl+g": true,
357
+ "shift+tab": true,
358
+ "shift+ctrl+p": true,
359
+ "alt+enter": true,
360
+ escape: true,
361
+ enter: true,
362
+ };
362
363
 
363
364
  getShortcuts(): Map<KeyId, ExtensionShortcut> {
364
365
  const allShortcuts = new Map<KeyId, ExtensionShortcut>();
@@ -366,7 +367,7 @@ export class ExtensionRunner {
366
367
  for (const [key, shortcut] of ext.shortcuts) {
367
368
  const normalizedKey = key.toLowerCase() as KeyId;
368
369
 
369
- if (ExtensionRunner.#RESERVED_SHORTCUTS.has(normalizedKey)) {
370
+ if (ExtensionRunner.#RESERVED_SHORTCUTS[normalizedKey]) {
370
371
  logger.warn("Extension shortcut conflicts with built-in shortcut", {
371
372
  key,
372
373
  extensionPath: shortcut.extensionPath,
@@ -419,6 +420,10 @@ export class ExtensionRunner {
419
420
  return undefined;
420
421
  }
421
422
 
423
+ getAssistantThinkingRenderers(): AssistantThinkingRenderer[] {
424
+ return this.extensions.flatMap(ext => ext.assistantThinkingRenderers);
425
+ }
426
+
422
427
  getRegisteredCommands(reserved?: Set<string>): RegisteredCommand[] {
423
428
  this.#commandDiagnostics = [];
424
429
 
@@ -25,6 +25,8 @@ import type {
25
25
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
26
26
  import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
27
27
  import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
28
+ import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
29
+ import type * as Zod from "zod/v4";
28
30
  import type { KeybindingsManager } from "../../config/keybindings";
29
31
  import type { ModelRegistry } from "../../config/model-registry";
30
32
  import type { EditToolDetails } from "../../edit";
@@ -81,6 +83,7 @@ import type {
81
83
  TurnStartEvent,
82
84
  } from "../shared-events";
83
85
  import type { SlashCommandInfo } from "../slash-commands";
86
+ import type * as TypeBox from "../typebox";
84
87
 
85
88
  export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
86
89
  export type { ExecOptions, ExecResult } from "../../exec/exec";
@@ -90,6 +93,17 @@ export type { AgentToolResult, AgentToolUpdateCallback };
90
93
  // UI Context
91
94
  // ============================================================================
92
95
 
96
+ export interface ExtensionUISelectOption {
97
+ label: string;
98
+ description?: string;
99
+ }
100
+
101
+ export type ExtensionUISelectItem = string | ExtensionUISelectOption;
102
+
103
+ export function getExtensionUISelectOptionLabel(option: ExtensionUISelectItem): string {
104
+ return typeof option === "string" ? option : option.label;
105
+ }
106
+
93
107
  /**
94
108
  * UI dialog options for extensions.
95
109
  */
@@ -135,8 +149,12 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un
135
149
  // and may be invoked from event handlers that have already taken the agent
136
150
  // loop's lock — hooks intentionally cannot.
137
151
  export interface ExtensionUIContext {
138
- /** Show a selector and return the user's choice. */
139
- select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
152
+ /** Show a selector and return the selected label, even when an option also includes a description. */
153
+ select(
154
+ title: string,
155
+ options: ExtensionUISelectItem[],
156
+ dialogOptions?: ExtensionUIDialogOptions,
157
+ ): Promise<string | undefined>;
140
158
 
141
159
  /** Show a confirmation dialog. */
142
160
  confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise<boolean>;
@@ -799,6 +817,18 @@ export type MessageRenderer<T = unknown> = (
799
817
  theme: Theme,
800
818
  ) => Component | undefined;
801
819
 
820
+ export interface AssistantThinkingRenderContext {
821
+ contentIndex: number;
822
+ thinkingIndex: number;
823
+ text: string;
824
+ requestRender(): void;
825
+ }
826
+
827
+ export type AssistantThinkingRenderer = (
828
+ context: AssistantThinkingRenderContext,
829
+ theme: Theme,
830
+ ) => Component | undefined;
831
+
802
832
  // ============================================================================
803
833
  // Command Registration
804
834
  // ============================================================================
@@ -830,13 +860,13 @@ export interface ExtensionAPI {
830
860
  // =========================================================================
831
861
 
832
862
  /** File logger for error/warning/debug messages */
833
- logger: typeof import("@oh-my-pi/pi-utils").logger;
863
+ logger: typeof PiLogger;
834
864
 
835
865
  /** Injected zod-backed typebox shim for legacy `Type.Object(...)` parameter authoring. */
836
- typebox: typeof import("../typebox");
866
+ typebox: typeof TypeBox;
837
867
 
838
868
  /** Injected zod module for Zod-authored extension tools (canonical going forward). */
839
- zod: typeof import("zod/v4");
869
+ zod: typeof Zod;
840
870
 
841
871
  /** Injected pi-coding-agent exports for accessing SDK utilities */
842
872
  pi: typeof piCodingAgent;
@@ -950,6 +980,9 @@ export interface ExtensionAPI {
950
980
  /** Register a custom renderer for CustomMessageEntry. */
951
981
  registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
952
982
 
983
+ /** Register a renderer for assistant thinking blocks. Rendered after the original thinking text. */
984
+ registerAssistantThinkingRenderer(renderer: AssistantThinkingRenderer): void;
985
+
953
986
  // =========================================================================
954
987
  // Actions
955
988
  // =========================================================================
@@ -1232,6 +1265,7 @@ export interface Extension {
1232
1265
  label?: string;
1233
1266
  handlers: Map<string, HandlerFn[]>;
1234
1267
  tools: Map<string, RegisteredTool<any, any>>;
1268
+ assistantThinkingRenderers: AssistantThinkingRenderer[];
1235
1269
  messageRenderers: Map<string, MessageRenderer>;
1236
1270
  commands: Map<string, RegisteredCommand>;
1237
1271
  flags: Map<string, ExtensionFlag>;