@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.70

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 (98) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +461 -0
  36. package/src/core/python-kernel.ts +1182 -0
  37. package/src/core/python-modules.test.ts +102 -0
  38. package/src/core/python-modules.ts +110 -0
  39. package/src/core/python-prelude.py +889 -0
  40. package/src/core/python-prelude.test.ts +140 -0
  41. package/src/core/python-prelude.ts +3 -0
  42. package/src/core/sdk.ts +24 -6
  43. package/src/core/session-manager.ts +174 -82
  44. package/src/core/settings-manager-python.test.ts +23 -0
  45. package/src/core/settings-manager.ts +202 -0
  46. package/src/core/streaming-output.test.ts +26 -0
  47. package/src/core/streaming-output.ts +100 -0
  48. package/src/core/system-prompt.python.test.ts +17 -0
  49. package/src/core/system-prompt.ts +3 -1
  50. package/src/core/timings.ts +1 -1
  51. package/src/core/tools/bash.ts +13 -2
  52. package/src/core/tools/edit-diff.ts +9 -1
  53. package/src/core/tools/index.test.ts +50 -23
  54. package/src/core/tools/index.ts +83 -1
  55. package/src/core/tools/python-execution.test.ts +68 -0
  56. package/src/core/tools/python-fallback.test.ts +72 -0
  57. package/src/core/tools/python-renderer.test.ts +36 -0
  58. package/src/core/tools/python-tool-mode.test.ts +43 -0
  59. package/src/core/tools/python.test.ts +121 -0
  60. package/src/core/tools/python.ts +760 -0
  61. package/src/core/tools/renderers.ts +2 -0
  62. package/src/core/tools/schema-validation.test.ts +1 -0
  63. package/src/core/tools/task/executor.ts +146 -3
  64. package/src/core/tools/task/worker-protocol.ts +32 -2
  65. package/src/core/tools/task/worker.ts +182 -15
  66. package/src/index.ts +6 -0
  67. package/src/main.ts +136 -40
  68. package/src/modes/interactive/components/custom-editor.ts +16 -31
  69. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  70. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  71. package/src/modes/interactive/components/history-search.ts +5 -8
  72. package/src/modes/interactive/components/hook-editor.ts +3 -4
  73. package/src/modes/interactive/components/hook-input.ts +3 -3
  74. package/src/modes/interactive/components/hook-selector.ts +5 -15
  75. package/src/modes/interactive/components/index.ts +1 -0
  76. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  77. package/src/modes/interactive/components/model-selector.ts +53 -66
  78. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  79. package/src/modes/interactive/components/session-selector.ts +29 -23
  80. package/src/modes/interactive/components/settings-defs.ts +404 -196
  81. package/src/modes/interactive/components/settings-selector.ts +14 -10
  82. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  83. package/src/modes/interactive/components/tool-execution.ts +8 -0
  84. package/src/modes/interactive/components/tree-selector.ts +29 -23
  85. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  86. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  87. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  88. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  89. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  90. package/src/modes/interactive/interactive-mode.ts +56 -30
  91. package/src/modes/interactive/theme/theme-schema.json +2 -2
  92. package/src/modes/interactive/types.ts +6 -1
  93. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  94. package/src/modes/print-mode.ts +23 -0
  95. package/src/modes/rpc/rpc-mode.ts +21 -0
  96. package/src/prompts/agents/reviewer.md +1 -1
  97. package/src/prompts/system/system-prompt.md +32 -1
  98. package/src/prompts/tools/python.md +91 -0
@@ -25,6 +25,7 @@ import {
25
25
  calculateContextTokens,
26
26
  collectEntriesForBranchSummary,
27
27
  compact,
28
+ estimateTokens,
28
29
  generateBranchSummary,
29
30
  prepareCompaction,
30
31
  shouldCompact,
@@ -43,6 +44,7 @@ import type {
43
44
  TurnEndEvent,
44
45
  TurnStartEvent,
45
46
  } from "./extensions";
47
+ import type { CompactOptions, ContextUsage } from "./extensions/types";
46
48
  import { extractFileMentions, generateFileMentionMessages } from "./file-mentions";
47
49
  import type { HookCommandContext } from "./hooks/types";
48
50
  import { logger } from "./logger";
@@ -65,7 +67,13 @@ import type { TtsrManager } from "./ttsr";
65
67
  export type AgentSessionEvent =
66
68
  | AgentEvent
67
69
  | { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
68
- | { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean }
70
+ | {
71
+ type: "auto_compaction_end";
72
+ result: CompactionResult | undefined;
73
+ aborted: boolean;
74
+ willRetry: boolean;
75
+ errorMessage?: string;
76
+ }
69
77
  | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
70
78
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
71
79
  | { type: "ttsr_triggered"; rules: Rule[] }
@@ -906,6 +914,7 @@ export class AgentSession {
906
914
  process.exit(0);
907
915
  },
908
916
  hasQueuedMessages: () => this.queuedMessageCount > 0,
917
+ getContextUsage: () => this.getContextUsage(),
909
918
  waitForIdle: () => this.agent.waitForIdle(),
910
919
  newSession: async (options) => {
911
920
  const success = await this.newSession({ parentSession: options?.parentSession });
@@ -925,6 +934,12 @@ export class AgentSession {
925
934
  const result = await this.navigateTree(targetId, { summarize: options?.summarize });
926
935
  return { cancelled: result.cancelled };
927
936
  },
937
+ compact: async (instructionsOrOptions) => {
938
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
939
+ const options =
940
+ instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
941
+ await this.compact(instructions, options);
942
+ },
928
943
  };
929
944
  }
930
945
 
@@ -967,7 +982,7 @@ export class AgentSession {
967
982
  });
968
983
  } else {
969
984
  const message = err instanceof Error ? err.message : String(err);
970
- console.error(`Custom command "${commandName}" failed: ${message}`);
985
+ logger.error("Custom command failed", { commandName, error: message });
971
986
  }
972
987
  return ""; // Command was handled (with error)
973
988
  }
@@ -1533,8 +1548,9 @@ export class AgentSession {
1533
1548
  * Manually compact the session context.
1534
1549
  * Aborts current agent operation first.
1535
1550
  * @param customInstructions Optional instructions for the compaction summary
1551
+ * @param options Optional callbacks for completion/error handling
1536
1552
  */
1537
- async compact(customInstructions?: string): Promise<CompactionResult> {
1553
+ async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
1538
1554
  this._disconnectFromAgent();
1539
1555
  await this.abort();
1540
1556
  this._compactionAbortController = new AbortController();
@@ -1632,12 +1648,18 @@ export class AgentSession {
1632
1648
  });
1633
1649
  }
1634
1650
 
1635
- return {
1651
+ const compactionResult: CompactionResult = {
1636
1652
  summary,
1637
1653
  firstKeptEntryId,
1638
1654
  tokensBefore,
1639
1655
  details,
1640
1656
  };
1657
+ options?.onComplete?.(compactionResult);
1658
+ return compactionResult;
1659
+ } catch (error) {
1660
+ const err = error instanceof Error ? error : new Error(String(error));
1661
+ options?.onError?.(err);
1662
+ throw error;
1641
1663
  } finally {
1642
1664
  this._compactionAbortController = undefined;
1643
1665
  this._reconnectToAgent();
@@ -2046,15 +2068,17 @@ export class AgentSession {
2046
2068
  }, 100);
2047
2069
  }
2048
2070
  } catch (error) {
2049
- this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
2050
-
2051
- if (reason === "overflow") {
2052
- throw new Error(
2053
- `Context overflow: ${
2054
- error instanceof Error ? error.message : "compaction failed"
2055
- }. Your input may be too large for the context window.`,
2056
- );
2057
- }
2071
+ const errorMessage = error instanceof Error ? error.message : "compaction failed";
2072
+ this._emit({
2073
+ type: "auto_compaction_end",
2074
+ result: undefined,
2075
+ aborted: false,
2076
+ willRetry: false,
2077
+ errorMessage:
2078
+ reason === "overflow"
2079
+ ? `Context overflow recovery failed: ${errorMessage}`
2080
+ : `Auto-compaction failed: ${errorMessage}`,
2081
+ });
2058
2082
  } finally {
2059
2083
  this._autoCompactionAbortController = undefined;
2060
2084
  }
@@ -2092,8 +2116,8 @@ export class AgentSession {
2092
2116
  }
2093
2117
 
2094
2118
  private _isRetryableErrorMessage(errorMessage: string): boolean {
2095
- // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error
2096
- return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
2119
+ // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed
2120
+ return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed/i.test(
2097
2121
  errorMessage,
2098
2122
  );
2099
2123
  }
@@ -2804,6 +2828,85 @@ export class AgentSession {
2804
2828
  };
2805
2829
  }
2806
2830
 
2831
+ /**
2832
+ * Get current context usage statistics.
2833
+ * Uses the last assistant message's usage data when available,
2834
+ * otherwise estimates tokens for all messages.
2835
+ */
2836
+ getContextUsage(): ContextUsage | undefined {
2837
+ const model = this.model;
2838
+ if (!model) return undefined;
2839
+
2840
+ const contextWindow = model.contextWindow ?? 0;
2841
+ if (contextWindow <= 0) return undefined;
2842
+
2843
+ const estimate = this._estimateContextTokens();
2844
+ const percent = (estimate.tokens / contextWindow) * 100;
2845
+
2846
+ return {
2847
+ tokens: estimate.tokens,
2848
+ contextWindow,
2849
+ percent,
2850
+ usageTokens: estimate.usageTokens,
2851
+ trailingTokens: estimate.trailingTokens,
2852
+ lastUsageIndex: estimate.lastUsageIndex,
2853
+ };
2854
+ }
2855
+
2856
+ /**
2857
+ * Estimate context tokens from messages, using the last assistant usage when available.
2858
+ */
2859
+ private _estimateContextTokens(): {
2860
+ tokens: number;
2861
+ usageTokens: number;
2862
+ trailingTokens: number;
2863
+ lastUsageIndex: number | null;
2864
+ } {
2865
+ const messages = this.messages;
2866
+
2867
+ // Find last assistant message with usage
2868
+ let lastUsageIndex: number | null = null;
2869
+ let lastUsage: Usage | undefined;
2870
+ for (let i = messages.length - 1; i >= 0; i--) {
2871
+ const msg = messages[i];
2872
+ if (msg.role === "assistant") {
2873
+ const assistantMsg = msg as AssistantMessage;
2874
+ if (assistantMsg.usage) {
2875
+ lastUsage = assistantMsg.usage;
2876
+ lastUsageIndex = i;
2877
+ break;
2878
+ }
2879
+ }
2880
+ }
2881
+
2882
+ if (!lastUsage || lastUsageIndex === null) {
2883
+ // No usage data - estimate all messages
2884
+ let estimated = 0;
2885
+ for (const message of messages) {
2886
+ estimated += estimateTokens(message);
2887
+ }
2888
+ return {
2889
+ tokens: estimated,
2890
+ usageTokens: 0,
2891
+ trailingTokens: estimated,
2892
+ lastUsageIndex: null,
2893
+ };
2894
+ }
2895
+
2896
+ const usageTokens = calculateContextTokens(lastUsage);
2897
+ let trailingTokens = 0;
2898
+ for (let i = lastUsageIndex + 1; i < messages.length; i++) {
2899
+ trailingTokens += estimateTokens(messages[i]);
2900
+ }
2901
+
2902
+ return {
2903
+ tokens: usageTokens + trailingTokens,
2904
+ usageTokens,
2905
+ trailingTokens,
2906
+ lastUsageIndex,
2907
+ };
2908
+ }
2909
+
2807
2910
  /**
2808
2911
  * Export session to HTML.
2809
2912
  * @param outputPath Optional output path (defaults to session directory)
@@ -6,16 +6,12 @@
6
6
  * - Direct calls from modes that need bash execution
7
7
  */
8
8
 
9
- import { createWriteStream, type WriteStream } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { join } from "node:path";
12
9
  import type { Subprocess } from "bun";
13
- import { nanoid } from "nanoid";
14
- import stripAnsi from "strip-ansi";
15
- import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell";
10
+ import { getShellConfig, killProcessTree } from "../utils/shell";
16
11
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
12
+ import { createOutputSink, pumpStream } from "./streaming-output";
17
13
  import type { BashOperations } from "./tools/bash";
18
- import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate";
14
+ import { DEFAULT_MAX_BYTES } from "./tools/truncate";
19
15
  import { ScopeSignal } from "./utils";
20
16
 
21
17
  // ============================================================================
@@ -50,83 +46,6 @@ export interface BashResult {
50
46
  // Implementation
51
47
  // ============================================================================
52
48
 
53
- function createSanitizer(): TransformStream<Uint8Array, string> {
54
- const decoder = new TextDecoder();
55
- return new TransformStream({
56
- transform(chunk, controller) {
57
- const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(chunk, { stream: true }))).replace(/\r/g, "");
58
- controller.enqueue(text);
59
- },
60
- });
61
- }
62
-
63
- async function pumpStream(readable: ReadableStream<Uint8Array>, writer: WritableStreamDefaultWriter<string>) {
64
- const reader = readable.pipeThrough(createSanitizer()).getReader();
65
- try {
66
- while (true) {
67
- const { done, value } = await reader.read();
68
- if (done) break;
69
- await writer.write(value);
70
- }
71
- } finally {
72
- reader.releaseLock();
73
- }
74
- }
75
-
76
- function createOutputSink(
77
- spillThreshold: number,
78
- maxBuffer: number,
79
- onChunk?: (text: string) => void,
80
- ): WritableStream<string> & {
81
- dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
82
- } {
83
- const chunks: string[] = [];
84
- let chunkBytes = 0;
85
- let totalBytes = 0;
86
- let fullOutputPath: string | undefined;
87
- let fullOutputStream: WriteStream | undefined;
88
-
89
- const sink = new WritableStream<string>({
90
- write(text) {
91
- totalBytes += text.length;
92
-
93
- // Spill to temp file if needed
94
- if (totalBytes > spillThreshold && !fullOutputPath) {
95
- fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
96
- const ts = createWriteStream(fullOutputPath);
97
- chunks.forEach((c) => {
98
- ts.write(c);
99
- });
100
- fullOutputStream = ts;
101
- }
102
- fullOutputStream?.write(text);
103
-
104
- // Rolling buffer
105
- chunks.push(text);
106
- chunkBytes += text.length;
107
- while (chunkBytes > maxBuffer && chunks.length > 1) {
108
- chunkBytes -= chunks.shift()!.length;
109
- }
110
-
111
- onChunk?.(text);
112
- },
113
- close() {
114
- fullOutputStream?.end();
115
- },
116
- });
117
-
118
- return Object.assign(sink, {
119
- dump(annotation?: string) {
120
- if (annotation) {
121
- chunks.push(`\n\n${annotation}`);
122
- }
123
- const full = chunks.join("");
124
- const { content, truncated } = truncateTail(full);
125
- return { output: truncated ? content : full, truncated, fullOutputPath: fullOutputPath };
126
- },
127
- });
128
- }
129
-
130
49
  /**
131
50
  * Execute a bash command with optional streaming and cancellation support.
132
51
  *
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
9
  import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai";
10
- import { complete, completeSimple } from "@oh-my-pi/pi-ai";
10
+ import { completeSimple } from "@oh-my-pi/pi-ai";
11
11
  import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" };
12
12
  import compactionTurnPrefixPrompt from "../../prompts/compaction/compaction-turn-prefix.md" with { type: "text" };
13
13
  import compactionUpdateSummaryPrompt from "../../prompts/compaction/compaction-update-summary.md" with { type: "text" };
@@ -642,17 +642,22 @@ async function generateTurnPrefixSummary(
642
642
  ): Promise<string> {
643
643
  const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
644
644
 
645
- const transformedMessages = convertToLlm(messages);
645
+ const llmMessages = convertToLlm(messages);
646
+ const conversationText = serializeConversation(llmMessages);
647
+ const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
646
648
  const summarizationMessages = [
647
- ...transformedMessages,
648
649
  {
649
650
  role: "user" as const,
650
- content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
651
+ content: [{ type: "text" as const, text: promptText }],
651
652
  timestamp: Date.now(),
652
653
  },
653
654
  ];
654
655
 
655
- const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
656
+ const response = await completeSimple(
657
+ model,
658
+ { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
659
+ { maxTokens, signal, apiKey, reasoning: "high" },
660
+ );
656
661
 
657
662
  if (response.stopReason === "error") {
658
663
  throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
@@ -54,6 +54,8 @@ export type {
54
54
  GetAllToolsHandler,
55
55
  GetThinkingLevelHandler,
56
56
  GrepToolResultEvent,
57
+ InputEvent,
58
+ InputEventResult,
57
59
  KeybindingsManager,
58
60
  LoadExtensionsResult,
59
61
  LsToolResultEvent,
@@ -49,6 +49,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
49
49
  sendMessage: notInitialized,
50
50
  sendUserMessage: notInitialized,
51
51
  appendEntry: notInitialized,
52
+ setLabel: notInitialized,
52
53
  getActiveTools: notInitialized,
53
54
  getAllTools: notInitialized,
54
55
  setActiveTools: notInitialized,
@@ -88,10 +89,21 @@ function createExtensionAPI(
88
89
  });
89
90
  },
90
91
 
91
- registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
92
+ registerCommand(
93
+ name: string,
94
+ options: {
95
+ description?: string;
96
+ getArgumentCompletions?: RegisteredCommand["getArgumentCompletions"];
97
+ handler: RegisteredCommand["handler"];
98
+ },
99
+ ): void {
92
100
  extension.commands.set(name, { name, ...options });
93
101
  },
94
102
 
103
+ setLabel(label: string): void {
104
+ extension.label = label;
105
+ },
106
+
95
107
  registerShortcut(
96
108
  shortcut: KeyId,
97
109
  options: {
@@ -12,8 +12,10 @@ import type { SessionManager } from "../session-manager";
12
12
  import type {
13
13
  BeforeAgentStartEvent,
14
14
  BeforeAgentStartEventResult,
15
+ CompactOptions,
15
16
  ContextEvent,
16
17
  ContextEventResult,
18
+ ContextUsage,
17
19
  Extension,
18
20
  ExtensionActions,
19
21
  ExtensionCommandContext,
@@ -26,6 +28,8 @@ import type {
26
28
  ExtensionRuntime,
27
29
  ExtensionShortcut,
28
30
  ExtensionUIContext,
31
+ InputEvent,
32
+ InputEventResult,
29
33
  MessageRenderer,
30
34
  RegisteredCommand,
31
35
  RegisteredTool,
@@ -110,6 +114,8 @@ export class ExtensionRunner {
110
114
  private waitForIdleFn: () => Promise<void> = async () => {};
111
115
  private abortFn: () => void = () => {};
112
116
  private hasPendingMessagesFn: () => boolean = () => false;
117
+ private getContextUsageFn: () => ContextUsage | undefined = () => undefined;
118
+ private compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
113
119
  private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
114
120
  private branchHandler: BranchHandler = async () => ({ cancelled: false });
115
121
  private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
@@ -160,6 +166,8 @@ export class ExtensionRunner {
160
166
  this.newSessionHandler = commandContextActions.newSession;
161
167
  this.branchHandler = commandContextActions.branch;
162
168
  this.navigateTreeHandler = commandContextActions.navigateTree;
169
+ this.getContextUsageFn = commandContextActions.getContextUsage;
170
+ this.compactFn = commandContextActions.compact;
163
171
  }
164
172
 
165
173
  this.uiContext = uiContext ?? noOpUIContext;
@@ -299,18 +307,23 @@ export class ExtensionRunner {
299
307
  }
300
308
 
301
309
  createContext(): ExtensionContext {
310
+ const getModel = this.getModel;
302
311
  return {
303
312
  ui: this.uiContext,
313
+ getContextUsage: () => this.getContextUsageFn(),
314
+ compact: (instructionsOrOptions) => this.compactFn(instructionsOrOptions),
304
315
  hasUI: this.hasUI(),
305
316
  cwd: this.cwd,
306
317
  sessionManager: this.sessionManager,
307
318
  modelRegistry: this.modelRegistry,
308
- model: this.getModel(),
319
+ get model() {
320
+ return getModel();
321
+ },
309
322
  isIdle: () => this.isIdleFn(),
310
323
  abort: () => this.abortFn(),
311
324
  hasPendingMessages: () => this.hasPendingMessagesFn(),
312
325
  shutdown: () => this.shutdownHandler(),
313
- hasQueuedMessages: () => this.hasPendingMessagesFn(),
326
+ hasQueuedMessages: () => this.hasPendingMessagesFn(), // deprecated alias
314
327
  };
315
328
  }
316
329
 
@@ -324,10 +337,12 @@ export class ExtensionRunner {
324
337
  createCommandContext(): ExtensionCommandContext {
325
338
  return {
326
339
  ...this.createContext(),
340
+ getContextUsage: () => this.getContextUsageFn(),
327
341
  waitForIdle: () => this.waitForIdleFn(),
328
342
  newSession: (options) => this.newSessionHandler(options),
329
343
  branch: (entryId) => this.branchHandler(entryId),
330
344
  navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
345
+ compact: (instructionsOrOptions) => this.compactFn(instructionsOrOptions),
331
346
  };
332
347
  }
333
348
 
@@ -446,6 +461,39 @@ export class ExtensionRunner {
446
461
  return undefined;
447
462
  }
448
463
 
464
+ /** Emit input event. Transforms chain, "handled" short-circuits. */
465
+ async emitInput(
466
+ text: string,
467
+ images: ImageContent[] | undefined,
468
+ source: "interactive" | "rpc" | "extension",
469
+ ): Promise<InputEventResult> {
470
+ const ctx = this.createContext();
471
+ let currentText = text;
472
+ let currentImages = images;
473
+
474
+ for (const ext of this.extensions) {
475
+ for (const handler of ext.handlers.get("input") ?? []) {
476
+ try {
477
+ const event: InputEvent = { type: "input", text: currentText, images: currentImages, source };
478
+ const result = (await handler(event, ctx)) as InputEventResult | undefined;
479
+ if (result?.handled) return result;
480
+ if (result?.text !== undefined) {
481
+ currentText = result.text;
482
+ currentImages = result.images ?? currentImages;
483
+ }
484
+ } catch (err) {
485
+ this.emitError({
486
+ extensionPath: ext.path,
487
+ event: "input",
488
+ error: err instanceof Error ? err.message : String(err),
489
+ stack: err instanceof Error ? err.stack : undefined,
490
+ });
491
+ }
492
+ }
493
+ }
494
+ return currentText !== text || currentImages !== images ? { text: currentText, images: currentImages } : {};
495
+ }
496
+
449
497
  async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
450
498
  const ctx = this.createContext();
451
499
  let currentMessages = structuredClone(messages);
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
12
12
  import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
13
- import type { Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
13
+ import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
14
14
  import type { Static, TSchema } from "@sinclair/typebox";
15
15
  import type * as piCodingAgent from "../../index";
16
16
  import type { Theme } from "../../modes/interactive/theme/theme";
@@ -125,12 +125,30 @@ export interface ExtensionUIContext {
125
125
  // Extension Context
126
126
  // ============================================================================
127
127
 
128
+ export interface ContextUsage {
129
+ tokens: number;
130
+ contextWindow: number;
131
+ percent: number;
132
+ usageTokens: number;
133
+ trailingTokens: number;
134
+ lastUsageIndex: number | null;
135
+ }
136
+
137
+ export interface CompactOptions {
138
+ onComplete?: (result: CompactionResult) => void;
139
+ onError?: (error: Error) => void;
140
+ }
141
+
128
142
  /**
129
143
  * Context passed to extension event handlers.
130
144
  */
131
145
  export interface ExtensionContext {
132
146
  /** UI methods for user interaction */
133
147
  ui: ExtensionUIContext;
148
+ /** Get current context usage for the active model. */
149
+ getContextUsage(): ContextUsage | undefined;
150
+ /** Compact the session context (interactive mode shows UI). */
151
+ compact(instructionsOrOptions?: string | CompactOptions): Promise<void>;
134
152
  /** Whether UI is available (false in print/RPC mode) */
135
153
  hasUI: boolean;
136
154
  /** Current working directory */
@@ -158,6 +176,9 @@ export interface ExtensionContext {
158
176
  * Includes session control methods only safe in user-initiated commands.
159
177
  */
160
178
  export interface ExtensionCommandContext extends ExtensionContext {
179
+ /** Get current context usage for the active model. */
180
+ getContextUsage(): ContextUsage | undefined;
181
+
161
182
  /** Wait for the agent to finish streaming */
162
183
  waitForIdle(): Promise<void>;
163
184
 
@@ -172,6 +193,9 @@ export interface ExtensionCommandContext extends ExtensionContext {
172
193
 
173
194
  /** Navigate to a different point in the session tree. */
174
195
  navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
196
+
197
+ /** Compact the session context (interactive mode shows UI). */
198
+ compact(instructionsOrOptions?: string | CompactOptions): Promise<void>;
175
199
  }
176
200
 
177
201
  // ============================================================================
@@ -382,6 +406,18 @@ export interface UserBashEvent {
382
406
  cwd: string;
383
407
  }
384
408
 
409
+ // ============================================================================
410
+ // Input Events
411
+ // ============================================================================
412
+
413
+ /** Fired when the user submits input (interactive mode only). */
414
+ export interface InputEvent {
415
+ type: "input";
416
+ text: string;
417
+ images?: ImageContent[];
418
+ source: "interactive" | "rpc" | "extension";
419
+ }
420
+
385
421
  // ============================================================================
386
422
  // Tool Events
387
423
  // ============================================================================
@@ -486,6 +522,7 @@ export type ExtensionEvent =
486
522
  | TurnStartEvent
487
523
  | TurnEndEvent
488
524
  | UserBashEvent
525
+ | InputEvent
489
526
  | ToolCallEvent
490
527
  | ToolResultEvent;
491
528
 
@@ -502,6 +539,16 @@ export interface ToolCallEventResult {
502
539
  reason?: string;
503
540
  }
504
541
 
542
+ /** Result from input event handler */
543
+ export interface InputEventResult {
544
+ /** If true, the input was handled and should not continue through normal flow */
545
+ handled?: boolean;
546
+ /** Replace the input text */
547
+ text?: string;
548
+ /** Replace any pending images */
549
+ images?: ImageContent[];
550
+ }
551
+
505
552
  /** Result from user_bash event handler */
506
553
  export interface UserBashEventResult {
507
554
  /** Custom operations to use for execution */
@@ -565,6 +612,7 @@ export type MessageRenderer<T = unknown> = (
565
612
  export interface RegisteredCommand {
566
613
  name: string;
567
614
  description?: string;
615
+ getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null;
568
616
  handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
569
617
  }
570
618
 
@@ -622,6 +670,7 @@ export interface ExtensionAPI {
622
670
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
623
671
  on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
624
672
  on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
673
+ on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): void;
625
674
  on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
626
675
  on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
627
676
  on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
@@ -638,7 +687,14 @@ export interface ExtensionAPI {
638
687
  // =========================================================================
639
688
 
640
689
  /** Register a custom command. */
641
- registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
690
+ registerCommand(
691
+ name: string,
692
+ options: {
693
+ description?: string;
694
+ getArgumentCompletions?: RegisteredCommand["getArgumentCompletions"];
695
+ handler: RegisteredCommand["handler"];
696
+ },
697
+ ): void;
642
698
 
643
699
  /** Register a keyboard shortcut. */
644
700
  registerShortcut(
@@ -659,6 +715,9 @@ export interface ExtensionAPI {
659
715
  },
660
716
  ): void;
661
717
 
718
+ /** Set the display label for this extension, or set a label on a specific entry. */
719
+ setLabel(entryIdOrLabel: string, label?: string | undefined): void;
720
+
662
721
  /** Get the value of a registered CLI flag. */
663
722
  getFlag(name: string): boolean | string | undefined;
664
723
 
@@ -776,6 +835,7 @@ export interface ExtensionActions {
776
835
  sendMessage: SendMessageHandler;
777
836
  sendUserMessage: SendUserMessageHandler;
778
837
  appendEntry: AppendEntryHandler;
838
+ setLabel: (targetId: string, label: string | undefined) => void;
779
839
  getActiveTools: GetActiveToolsHandler;
780
840
  getAllTools: GetAllToolsHandler;
781
841
  setActiveTools: SetActiveToolsHandler;
@@ -791,10 +851,13 @@ export interface ExtensionContextActions {
791
851
  abort: () => void;
792
852
  hasPendingMessages: () => boolean;
793
853
  shutdown: () => void;
854
+ getContextUsage: () => ContextUsage | undefined;
855
+ compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
794
856
  }
795
857
 
796
858
  /** Actions for ExtensionCommandContext (ctx.* in command handlers). */
797
859
  export interface ExtensionCommandContextActions {
860
+ getContextUsage: () => ContextUsage | undefined;
798
861
  waitForIdle: () => Promise<void>;
799
862
  newSession: (options?: {
800
863
  parentSession?: string;
@@ -802,6 +865,7 @@ export interface ExtensionCommandContextActions {
802
865
  }) => Promise<{ cancelled: boolean }>;
803
866
  branch: (entryId: string) => Promise<{ cancelled: boolean }>;
804
867
  navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>;
868
+ compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
805
869
  }
806
870
 
807
871
  /** Full runtime = state + actions. */
@@ -811,6 +875,7 @@ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionAction
811
875
  export interface Extension {
812
876
  path: string;
813
877
  resolvedPath: string;
878
+ label?: string;
814
879
  handlers: Map<string, HandlerFn[]>;
815
880
  tools: Map<string, RegisteredTool>;
816
881
  messageRenderers: Map<string, MessageRenderer>;