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

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 (96) hide show
  1. package/CHANGELOG.md +98 -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 +549 -0
  36. package/src/core/python-kernel.ts +1178 -0
  37. package/src/core/python-prelude.py +889 -0
  38. package/src/core/python-prelude.test.ts +140 -0
  39. package/src/core/python-prelude.ts +3 -0
  40. package/src/core/sdk.ts +24 -6
  41. package/src/core/session-manager.ts +174 -82
  42. package/src/core/settings-manager-python.test.ts +23 -0
  43. package/src/core/settings-manager.ts +202 -0
  44. package/src/core/streaming-output.test.ts +26 -0
  45. package/src/core/streaming-output.ts +100 -0
  46. package/src/core/system-prompt.python.test.ts +17 -0
  47. package/src/core/system-prompt.ts +3 -1
  48. package/src/core/timings.ts +1 -1
  49. package/src/core/tools/bash.ts +13 -2
  50. package/src/core/tools/edit-diff.ts +9 -1
  51. package/src/core/tools/index.test.ts +50 -23
  52. package/src/core/tools/index.ts +83 -1
  53. package/src/core/tools/python-execution.test.ts +68 -0
  54. package/src/core/tools/python-fallback.test.ts +72 -0
  55. package/src/core/tools/python-renderer.test.ts +36 -0
  56. package/src/core/tools/python-tool-mode.test.ts +43 -0
  57. package/src/core/tools/python.test.ts +121 -0
  58. package/src/core/tools/python.ts +760 -0
  59. package/src/core/tools/renderers.ts +2 -0
  60. package/src/core/tools/schema-validation.test.ts +1 -0
  61. package/src/core/tools/task/executor.ts +146 -3
  62. package/src/core/tools/task/worker-protocol.ts +32 -2
  63. package/src/core/tools/task/worker.ts +182 -15
  64. package/src/index.ts +6 -0
  65. package/src/main.ts +136 -40
  66. package/src/modes/interactive/components/custom-editor.ts +16 -31
  67. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  68. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  69. package/src/modes/interactive/components/history-search.ts +5 -8
  70. package/src/modes/interactive/components/hook-editor.ts +3 -4
  71. package/src/modes/interactive/components/hook-input.ts +3 -3
  72. package/src/modes/interactive/components/hook-selector.ts +5 -15
  73. package/src/modes/interactive/components/index.ts +1 -0
  74. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  75. package/src/modes/interactive/components/model-selector.ts +53 -66
  76. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  77. package/src/modes/interactive/components/session-selector.ts +29 -23
  78. package/src/modes/interactive/components/settings-defs.ts +404 -196
  79. package/src/modes/interactive/components/settings-selector.ts +14 -10
  80. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  81. package/src/modes/interactive/components/tool-execution.ts +8 -0
  82. package/src/modes/interactive/components/tree-selector.ts +29 -23
  83. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  84. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  85. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  86. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  87. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  88. package/src/modes/interactive/interactive-mode.ts +56 -30
  89. package/src/modes/interactive/theme/theme-schema.json +2 -2
  90. package/src/modes/interactive/types.ts +6 -1
  91. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  92. package/src/modes/print-mode.ts +23 -0
  93. package/src/modes/rpc/rpc-mode.ts +21 -0
  94. package/src/prompts/agents/reviewer.md +1 -1
  95. package/src/prompts/system/system-prompt.md +32 -1
  96. package/src/prompts/tools/python.md +91 -0
@@ -46,6 +46,10 @@ export interface TerminalSettings {
46
46
  showImages?: boolean; // default: true (only relevant if terminal supports images)
47
47
  }
48
48
 
49
+ export interface StartupSettings {
50
+ quiet?: boolean; // default: false - suppress welcome screen and startup info
51
+ }
52
+
49
53
  export interface ImageSettings {
50
54
  autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
51
55
  blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers
@@ -108,6 +112,15 @@ export interface LspSettings {
108
112
  diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files)
109
113
  }
110
114
 
115
+ export type PythonToolMode = "ipy-only" | "bash-only" | "both";
116
+ export type PythonKernelMode = "session" | "per-call";
117
+
118
+ export interface PythonSettings {
119
+ toolMode?: PythonToolMode;
120
+ kernelMode?: PythonKernelMode;
121
+ sharedGateway?: boolean;
122
+ }
123
+
111
124
  export interface EditSettings {
112
125
  fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
113
126
  }
@@ -194,6 +207,7 @@ export interface Settings {
194
207
  hideThinkingBlock?: boolean;
195
208
  shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
196
209
  collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
210
+ startup?: StartupSettings;
197
211
  doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
198
212
  thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
199
213
  /** Environment variables to set automatically on startup */
@@ -210,6 +224,7 @@ export interface Settings {
210
224
  git?: GitSettings;
211
225
  mcp?: MCPSettings;
212
226
  lsp?: LspSettings;
227
+ python?: PythonSettings;
213
228
  edit?: EditSettings;
214
229
  ttsr?: TtsrSettings;
215
230
  todoCompletion?: TodoCompletionSettings;
@@ -218,6 +233,7 @@ export interface Settings {
218
233
  disabledProviders?: string[]; // Discovery provider IDs that are disabled
219
234
  disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
220
235
  statusLine?: StatusLineSettings; // Status line configuration
236
+ showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
221
237
  }
222
238
 
223
239
  export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
@@ -301,6 +317,7 @@ const DEFAULT_SETTINGS: Settings = {
301
317
  git: { enabled: false },
302
318
  mcp: { enableProjectConfig: true },
303
319
  lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false },
320
+ python: { toolMode: "both", kernelMode: "session", sharedGateway: true },
304
321
  edit: { fuzzyMatch: true },
305
322
  ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 },
306
323
  voice: {
@@ -377,6 +394,25 @@ function normalizeSettings(settings: Settings): Settings {
377
394
  ...merged,
378
395
  symbolPreset,
379
396
  bashInterceptor: normalizeBashInterceptorSettings(merged.bashInterceptor),
397
+ python: normalizePythonSettings(merged.python),
398
+ };
399
+ }
400
+
401
+ function normalizePythonSettings(settings: PythonSettings | undefined): PythonSettings {
402
+ const toolMode = settings?.toolMode;
403
+ const kernelMode = settings?.kernelMode;
404
+ const sharedGateway = settings?.sharedGateway;
405
+ return {
406
+ toolMode:
407
+ toolMode === "ipy-only" || toolMode === "bash-only" || toolMode === "both"
408
+ ? toolMode
409
+ : (DEFAULT_SETTINGS.python?.toolMode ?? "both"),
410
+ kernelMode:
411
+ kernelMode === "session" || kernelMode === "per-call"
412
+ ? kernelMode
413
+ : (DEFAULT_SETTINGS.python?.kernelMode ?? "session"),
414
+ sharedGateway:
415
+ typeof sharedGateway === "boolean" ? sharedGateway : (DEFAULT_SETTINGS.python?.sharedGateway ?? true),
380
416
  };
381
417
  }
382
418
 
@@ -781,6 +817,30 @@ export class SettingsManager {
781
817
  };
782
818
  }
783
819
 
820
+ getRetryMaxRetries(): number {
821
+ return this.settings.retry?.maxRetries ?? 3;
822
+ }
823
+
824
+ async setRetryMaxRetries(maxRetries: number): Promise<void> {
825
+ if (!this.globalSettings.retry) {
826
+ this.globalSettings.retry = {};
827
+ }
828
+ this.globalSettings.retry.maxRetries = maxRetries;
829
+ await this.save();
830
+ }
831
+
832
+ getRetryBaseDelayMs(): number {
833
+ return this.settings.retry?.baseDelayMs ?? 2000;
834
+ }
835
+
836
+ async setRetryBaseDelayMs(baseDelayMs: number): Promise<void> {
837
+ if (!this.globalSettings.retry) {
838
+ this.globalSettings.retry = {};
839
+ }
840
+ this.globalSettings.retry.baseDelayMs = baseDelayMs;
841
+ await this.save();
842
+ }
843
+
784
844
  getTodoCompletionSettings(): { enabled: boolean; maxReminders: number } {
785
845
  return {
786
846
  enabled: this.settings.todoCompletion?.enabled ?? false,
@@ -843,6 +903,18 @@ export class SettingsManager {
843
903
  await this.save();
844
904
  }
845
905
 
906
+ getStartupQuiet(): boolean {
907
+ return this.settings.startup?.quiet ?? false;
908
+ }
909
+
910
+ async setStartupQuiet(quiet: boolean): Promise<void> {
911
+ if (!this.globalSettings.startup) {
912
+ this.globalSettings.startup = {};
913
+ }
914
+ this.globalSettings.startup.quiet = quiet;
915
+ await this.save();
916
+ }
917
+
846
918
  getExtensionPaths(): string[] {
847
919
  return [...(this.settings.extensions ?? [])];
848
920
  }
@@ -883,6 +955,14 @@ export class SettingsManager {
883
955
  return this.settings.skills?.enableSkillCommands ?? true;
884
956
  }
885
957
 
958
+ async setEnableSkillCommands(enabled: boolean): Promise<void> {
959
+ if (!this.globalSettings.skills) {
960
+ this.globalSettings.skills = {};
961
+ }
962
+ this.globalSettings.skills.enableSkillCommands = enabled;
963
+ await this.save();
964
+ }
965
+
886
966
  getCommandsSettings(): Required<CommandsSettings> {
887
967
  return {
888
968
  enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true,
@@ -890,6 +970,30 @@ export class SettingsManager {
890
970
  };
891
971
  }
892
972
 
973
+ getCommandsEnableClaudeUser(): boolean {
974
+ return this.settings.commands?.enableClaudeUser ?? true;
975
+ }
976
+
977
+ async setCommandsEnableClaudeUser(enabled: boolean): Promise<void> {
978
+ if (!this.globalSettings.commands) {
979
+ this.globalSettings.commands = {};
980
+ }
981
+ this.globalSettings.commands.enableClaudeUser = enabled;
982
+ await this.save();
983
+ }
984
+
985
+ getCommandsEnableClaudeProject(): boolean {
986
+ return this.settings.commands?.enableClaudeProject ?? true;
987
+ }
988
+
989
+ async setCommandsEnableClaudeProject(enabled: boolean): Promise<void> {
990
+ if (!this.globalSettings.commands) {
991
+ this.globalSettings.commands = {};
992
+ }
993
+ this.globalSettings.commands.enableClaudeProject = enabled;
994
+ await this.save();
995
+ }
996
+
893
997
  getShowImages(): boolean {
894
998
  return this.settings.terminal?.showImages ?? true;
895
999
  }
@@ -1046,10 +1150,54 @@ export class SettingsManager {
1046
1150
  await this.save();
1047
1151
  }
1048
1152
 
1153
+ async setBashInterceptorSimpleLsEnabled(enabled: boolean): Promise<void> {
1154
+ if (!this.globalSettings.bashInterceptor) {
1155
+ this.globalSettings.bashInterceptor = {};
1156
+ }
1157
+ this.globalSettings.bashInterceptor.simpleLs = enabled;
1158
+ await this.save();
1159
+ }
1160
+
1049
1161
  getGitToolEnabled(): boolean {
1050
1162
  return this.settings.git?.enabled ?? false;
1051
1163
  }
1052
1164
 
1165
+ getPythonToolMode(): PythonToolMode {
1166
+ return this.settings.python?.toolMode ?? "both";
1167
+ }
1168
+
1169
+ async setPythonToolMode(mode: PythonToolMode): Promise<void> {
1170
+ if (!this.globalSettings.python) {
1171
+ this.globalSettings.python = {};
1172
+ }
1173
+ this.globalSettings.python.toolMode = mode;
1174
+ await this.save();
1175
+ }
1176
+
1177
+ getPythonKernelMode(): PythonKernelMode {
1178
+ return this.settings.python?.kernelMode ?? "session";
1179
+ }
1180
+
1181
+ async setPythonKernelMode(mode: PythonKernelMode): Promise<void> {
1182
+ if (!this.globalSettings.python) {
1183
+ this.globalSettings.python = {};
1184
+ }
1185
+ this.globalSettings.python.kernelMode = mode;
1186
+ await this.save();
1187
+ }
1188
+
1189
+ getPythonSharedGateway(): boolean {
1190
+ return this.settings.python?.sharedGateway ?? true;
1191
+ }
1192
+
1193
+ async setPythonSharedGateway(enabled: boolean): Promise<void> {
1194
+ if (!this.globalSettings.python) {
1195
+ this.globalSettings.python = {};
1196
+ }
1197
+ this.globalSettings.python.sharedGateway = enabled;
1198
+ await this.save();
1199
+ }
1200
+
1053
1201
  async setGitToolEnabled(enabled: boolean): Promise<void> {
1054
1202
  if (!this.globalSettings.git) {
1055
1203
  this.globalSettings.git = {};
@@ -1244,6 +1392,42 @@ export class SettingsManager {
1244
1392
  await this.save();
1245
1393
  }
1246
1394
 
1395
+ getVoiceTtsModel(): string {
1396
+ return this.settings.voice?.ttsModel ?? "gpt-4o-mini-tts";
1397
+ }
1398
+
1399
+ async setVoiceTtsModel(model: string): Promise<void> {
1400
+ if (!this.globalSettings.voice) {
1401
+ this.globalSettings.voice = {};
1402
+ }
1403
+ this.globalSettings.voice.ttsModel = model;
1404
+ await this.save();
1405
+ }
1406
+
1407
+ getVoiceTtsVoice(): string {
1408
+ return this.settings.voice?.ttsVoice ?? "alloy";
1409
+ }
1410
+
1411
+ async setVoiceTtsVoice(voice: string): Promise<void> {
1412
+ if (!this.globalSettings.voice) {
1413
+ this.globalSettings.voice = {};
1414
+ }
1415
+ this.globalSettings.voice.ttsVoice = voice;
1416
+ await this.save();
1417
+ }
1418
+
1419
+ getVoiceTtsFormat(): "wav" | "mp3" | "opus" | "aac" | "flac" {
1420
+ return this.settings.voice?.ttsFormat ?? "wav";
1421
+ }
1422
+
1423
+ async setVoiceTtsFormat(format: "wav" | "mp3" | "opus" | "aac" | "flac"): Promise<void> {
1424
+ if (!this.globalSettings.voice) {
1425
+ this.globalSettings.voice = {};
1426
+ }
1427
+ this.globalSettings.voice.ttsFormat = format;
1428
+ await this.save();
1429
+ }
1430
+
1247
1431
  // ═══════════════════════════════════════════════════════════════════════════
1248
1432
  // Status Line Settings
1249
1433
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1374,6 +1558,24 @@ export class SettingsManager {
1374
1558
  await this.save();
1375
1559
  }
1376
1560
 
1561
+ getShowHardwareCursor(): boolean {
1562
+ // Check settings first
1563
+ if (this.settings.showHardwareCursor !== undefined) {
1564
+ return this.settings.showHardwareCursor;
1565
+ }
1566
+ // Check env var override
1567
+ const envVar = process.env.OMP_HARDWARE_CURSOR?.toLowerCase();
1568
+ if (envVar === "0" || envVar === "false") return false;
1569
+ if (envVar === "1" || envVar === "true") return true;
1570
+ // Default to true on Linux/macOS for IME support
1571
+ return process.platform === "linux" || process.platform === "darwin";
1572
+ }
1573
+
1574
+ async setShowHardwareCursor(show: boolean): Promise<void> {
1575
+ this.globalSettings.showHardwareCursor = show;
1576
+ await this.save();
1577
+ }
1578
+
1377
1579
  /**
1378
1580
  * Get environment variables from settings
1379
1581
  */
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createOutputSink } from "./streaming-output";
3
+
4
+ function makeLargeOutput(size: number): string {
5
+ return "x".repeat(size);
6
+ }
7
+
8
+ describe("createOutputSink", () => {
9
+ it("spills to disk and truncates large output", async () => {
10
+ const largeOutput = makeLargeOutput(60_000);
11
+ const sink = createOutputSink(10, 70_000);
12
+ const writer = sink.getWriter();
13
+
14
+ await writer.write(largeOutput);
15
+ await writer.close();
16
+
17
+ const result = sink.dump();
18
+
19
+ expect(result.truncated).toBe(true);
20
+ expect(result.fullOutputPath).toBeDefined();
21
+ expect(result.output.length).toBeLessThan(largeOutput.length);
22
+
23
+ const fullOutput = await Bun.file(result.fullOutputPath!).text();
24
+ expect(fullOutput).toBe(largeOutput);
25
+ });
26
+ });
@@ -0,0 +1,100 @@
1
+ import { tmpdir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { nanoid } from "nanoid";
4
+ import stripAnsi from "strip-ansi";
5
+ import { sanitizeBinaryOutput } from "../utils/shell";
6
+ import { truncateTail } from "./tools/truncate";
7
+
8
+ interface OutputFileSink {
9
+ write(data: string): number | Promise<number>;
10
+ end(): void;
11
+ }
12
+
13
+ export function createSanitizer(): TransformStream<Uint8Array, string> {
14
+ const decoder = new TextDecoder();
15
+ const sanitizeText = (text: string) => sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
16
+ return new TransformStream({
17
+ transform(chunk, controller) {
18
+ const text = sanitizeText(decoder.decode(chunk, { stream: true }));
19
+ if (text) {
20
+ controller.enqueue(text);
21
+ }
22
+ },
23
+ flush(controller) {
24
+ const text = sanitizeText(decoder.decode());
25
+ if (text) {
26
+ controller.enqueue(text);
27
+ }
28
+ },
29
+ });
30
+ }
31
+
32
+ export async function pumpStream(readable: ReadableStream<Uint8Array>, writer: WritableStreamDefaultWriter<string>) {
33
+ const reader = readable.pipeThrough(createSanitizer()).getReader();
34
+ try {
35
+ while (true) {
36
+ const { done, value } = await reader.read();
37
+ if (done) break;
38
+ await writer.write(value);
39
+ }
40
+ } finally {
41
+ reader.releaseLock();
42
+ }
43
+ }
44
+
45
+ export function createOutputSink(
46
+ spillThreshold: number,
47
+ maxBuffer: number,
48
+ onChunk?: (text: string) => void,
49
+ ): WritableStream<string> & {
50
+ dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
51
+ } {
52
+ const chunks: Array<{ text: string; bytes: number }> = [];
53
+ let chunkBytes = 0;
54
+ let totalBytes = 0;
55
+ let fullOutputPath: string | undefined;
56
+ let fullOutputStream: OutputFileSink | undefined;
57
+
58
+ const sink = new WritableStream<string>({
59
+ write(text) {
60
+ const bytes = Buffer.byteLength(text, "utf-8");
61
+ totalBytes += bytes;
62
+
63
+ if (totalBytes > spillThreshold && !fullOutputPath) {
64
+ fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
65
+ const stream = Bun.file(fullOutputPath).writer();
66
+ for (const chunk of chunks) {
67
+ stream.write(chunk.text);
68
+ }
69
+ fullOutputStream = stream;
70
+ }
71
+ fullOutputStream?.write(text);
72
+
73
+ chunks.push({ text, bytes });
74
+ chunkBytes += bytes;
75
+ while (chunkBytes > maxBuffer && chunks.length > 1) {
76
+ const removed = chunks.shift();
77
+ if (removed) {
78
+ chunkBytes -= removed.bytes;
79
+ }
80
+ }
81
+
82
+ onChunk?.(text);
83
+ },
84
+ close() {
85
+ fullOutputStream?.end();
86
+ },
87
+ });
88
+
89
+ return Object.assign(sink, {
90
+ dump(annotation?: string) {
91
+ if (annotation) {
92
+ const text = `\n\n${annotation}`;
93
+ chunks.push({ text, bytes: Buffer.byteLength(text, "utf-8") });
94
+ }
95
+ const full = chunks.map((chunk) => chunk.text).join("");
96
+ const { content, truncated } = truncateTail(full);
97
+ return { output: truncated ? content : full, truncated, fullOutputPath };
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildSystemPrompt } from "./system-prompt";
3
+
4
+ describe("buildSystemPrompt", () => {
5
+ it("includes python tool details when enabled", async () => {
6
+ const prompt = await buildSystemPrompt({
7
+ cwd: "/tmp",
8
+ toolNames: ["python"],
9
+ contextFiles: [],
10
+ skills: [],
11
+ rules: [],
12
+ });
13
+
14
+ expect(prompt).toContain("python: Execute Python code via a session-backed IPython kernel");
15
+ expect(prompt).toContain("What python IS for");
16
+ });
17
+ });
@@ -76,6 +76,7 @@ const toolDescriptions: Record<ToolName, string> = {
76
76
  ask: "Ask user for input or clarification",
77
77
  read: "Read file contents",
78
78
  bash: "Execute bash commands (npm, docker, etc.)",
79
+ python: "Execute Python code via a session-backed IPython kernel",
79
80
  calc: "{ calculations: array of { expression: string, prefix: string, suffix: string } } Basic calculations.",
80
81
  ssh: "Execute commands on remote hosts via SSH",
81
82
  edit: "Make surgical edits to files (find exact text and replace)",
@@ -672,7 +673,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
672
673
 
673
674
  // Build tool descriptions array
674
675
  // Priority: toolNames (explicit list) > tools (Map) > defaults
675
- const defaultToolNames: ToolName[] = ["read", "bash", "edit", "write"];
676
+ // Default includes both bash and python; actual availability determined by settings in createTools
677
+ const defaultToolNames: ToolName[] = ["read", "bash", "python", "edit", "write"];
676
678
  let toolNamesArray: string[];
677
679
  if (toolNames !== undefined) {
678
680
  // Explicit toolNames list provided (could be empty)
@@ -3,7 +3,7 @@
3
3
  * Enable with OMP_TIMING=1 or PI_TIMING=1 environment variable.
4
4
  */
5
5
 
6
- const ENABLED = process.env.OMP_TIMING === "1" || process.env.PI_TIMING === "1";
6
+ const ENABLED = process.env.OMP_TIMING === "1";
7
7
  const timings: Array<{ label: string; ms: number }> = [];
8
8
  let lastTime = Date.now();
9
9
 
@@ -178,6 +178,8 @@ interface BashRenderContext {
178
178
  expanded?: boolean;
179
179
  /** Number of preview lines when collapsed */
180
180
  previewLines?: number;
181
+ /** Timeout in seconds */
182
+ timeout?: number;
181
183
  }
182
184
 
183
185
  // Preview line limit when not expanded (matches tool-execution behavior)
@@ -236,6 +238,11 @@ export const bashToolRenderer = {
236
238
  // Build truncation warning lines (static, doesn't depend on width)
237
239
  const truncation = details?.truncation;
238
240
  const fullOutputPath = details?.fullOutputPath;
241
+ const timeoutSeconds = renderContext?.timeout;
242
+ const timeoutLine =
243
+ typeof timeoutSeconds === "number"
244
+ ? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`))
245
+ : undefined;
239
246
  let warningLine: string | undefined;
240
247
  if (fullOutputPath || (truncation?.truncated && !showingFullOutput)) {
241
248
  const warnings: string[] = [];
@@ -258,7 +265,8 @@ export const bashToolRenderer = {
258
265
 
259
266
  if (!displayOutput) {
260
267
  // No output - just show warning if any
261
- return new Text(warningLine ?? "", 0, 0);
268
+ const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
269
+ return new Text(lines.join("\n"), 0, 0);
262
270
  }
263
271
 
264
272
  if (expanded) {
@@ -267,7 +275,7 @@ export const bashToolRenderer = {
267
275
  .split("\n")
268
276
  .map((line) => uiTheme.fg("toolOutput", line))
269
277
  .join("\n");
270
- const lines = warningLine ? [styledOutput, warningLine] : [styledOutput];
278
+ const lines = [styledOutput, timeoutLine, warningLine].filter(Boolean) as string[];
271
279
  return new Text(lines.join("\n"), 0, 0);
272
280
  }
273
281
 
@@ -300,6 +308,9 @@ export const bashToolRenderer = {
300
308
  outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
301
309
  }
302
310
  outputLines.push(...cachedLines);
311
+ if (timeoutLine) {
312
+ outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
313
+ }
303
314
  if (warningLine) {
304
315
  outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis)));
305
316
  }
@@ -80,6 +80,13 @@ function computeRelativeIndentDepths(lines: string[]): number[] {
80
80
  });
81
81
  }
82
82
 
83
+ function normalizeFuzzyText(text: string): string {
84
+ return text
85
+ .replace(/[“”„‟«»]/g, '"')
86
+ .replace(/[‘’‚‛`´]/g, "'")
87
+ .replace(/[‐‑‒–—−]/g, "-");
88
+ }
89
+
83
90
  function normalizeLinesForMatch(lines: string[], includeDepth = true): string[] {
84
91
  const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null;
85
92
  return lines.map((line, index) => {
@@ -88,7 +95,8 @@ function normalizeLinesForMatch(lines: string[], includeDepth = true): string[]
88
95
  if (trimmed.length === 0) {
89
96
  return prefix;
90
97
  }
91
- const collapsed = trimmed.replace(/[ \t]+/g, " ");
98
+ const normalized = normalizeFuzzyText(trimmed);
99
+ const collapsed = normalized.replace(/[ \t]+/g, " ");
92
100
  return `${prefix}${collapsed}`;
93
101
  });
94
102
  }
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { BUILTIN_TOOLS, createTools, HIDDEN_TOOLS, type ToolSession } from "./index";
3
3
 
4
+ process.env.OMP_PYTHON_SKIP_CHECK = "1";
5
+
4
6
  function createTestSession(overrides: Partial<ToolSession> = {}): ToolSession {
5
7
  return {
6
8
  cwd: "/tmp/test",
@@ -11,6 +13,21 @@ function createTestSession(overrides: Partial<ToolSession> = {}): ToolSession {
11
13
  };
12
14
  }
13
15
 
16
+ function createBaseSettings(overrides: Partial<NonNullable<ToolSession["settings"]>> = {}) {
17
+ return {
18
+ getImageAutoResize: () => true,
19
+ getLspFormatOnWrite: () => true,
20
+ getLspDiagnosticsOnWrite: () => true,
21
+ getLspDiagnosticsOnEdit: () => false,
22
+ getEditFuzzyMatch: () => true,
23
+ getGitToolEnabled: () => true,
24
+ getBashInterceptorEnabled: () => true,
25
+ getBashInterceptorSimpleLsEnabled: () => true,
26
+ getBashInterceptorRules: () => [],
27
+ ...overrides,
28
+ };
29
+ }
30
+
14
31
  describe("createTools", () => {
15
32
  it("creates all builtin tools by default", async () => {
16
33
  const session = createTestSession();
@@ -18,7 +35,8 @@ describe("createTools", () => {
18
35
  const names = tools.map((t) => t.name);
19
36
 
20
37
  // Core tools should always be present
21
- expect(names).toContain("bash");
38
+ expect(names).toContain("python");
39
+ expect(names).not.toContain("bash");
22
40
  expect(names).toContain("calc");
23
41
  expect(names).toContain("read");
24
42
  expect(names).toContain("edit");
@@ -35,6 +53,34 @@ describe("createTools", () => {
35
53
  expect(names).toContain("web_search");
36
54
  });
37
55
 
56
+ it("includes bash and python when python mode is both", async () => {
57
+ const session = createTestSession({
58
+ settings: createBaseSettings({
59
+ getPythonToolMode: () => "both",
60
+ getPythonKernelMode: () => "session",
61
+ }),
62
+ });
63
+ const tools = await createTools(session);
64
+ const names = tools.map((t) => t.name);
65
+
66
+ expect(names).toContain("bash");
67
+ expect(names).toContain("python");
68
+ });
69
+
70
+ it("includes bash only when python mode is bash-only", async () => {
71
+ const session = createTestSession({
72
+ settings: createBaseSettings({
73
+ getPythonToolMode: () => "bash-only",
74
+ getPythonKernelMode: () => "session",
75
+ }),
76
+ });
77
+ const tools = await createTools(session);
78
+ const names = tools.map((t) => t.name);
79
+
80
+ expect(names).toContain("bash");
81
+ expect(names).not.toContain("python");
82
+ });
83
+
38
84
  it("excludes lsp tool when session disables LSP", async () => {
39
85
  const session = createTestSession({ enableLsp: false });
40
86
  const tools = await createTools(session, ["read", "lsp", "write"]);
@@ -93,17 +139,7 @@ describe("createTools", () => {
93
139
 
94
140
  it("excludes git tool when disabled in settings", async () => {
95
141
  const session = createTestSession({
96
- settings: {
97
- getImageAutoResize: () => true,
98
- getLspFormatOnWrite: () => true,
99
- getLspDiagnosticsOnWrite: () => true,
100
- getLspDiagnosticsOnEdit: () => false,
101
- getEditFuzzyMatch: () => true,
102
- getGitToolEnabled: () => false,
103
- getBashInterceptorEnabled: () => true,
104
- getBashInterceptorSimpleLsEnabled: () => true,
105
- getBashInterceptorRules: () => [],
106
- },
142
+ settings: createBaseSettings({ getGitToolEnabled: () => false }),
107
143
  });
108
144
  const tools = await createTools(session);
109
145
  const names = tools.map((t) => t.name);
@@ -113,17 +149,7 @@ describe("createTools", () => {
113
149
 
114
150
  it("includes git tool when enabled in settings", async () => {
115
151
  const session = createTestSession({
116
- settings: {
117
- getImageAutoResize: () => true,
118
- getLspFormatOnWrite: () => true,
119
- getLspDiagnosticsOnWrite: () => true,
120
- getLspDiagnosticsOnEdit: () => false,
121
- getEditFuzzyMatch: () => true,
122
- getGitToolEnabled: () => true,
123
- getBashInterceptorEnabled: () => true,
124
- getBashInterceptorSimpleLsEnabled: () => true,
125
- getBashInterceptorRules: () => [],
126
- },
152
+ settings: createBaseSettings({ getGitToolEnabled: () => true }),
127
153
  });
128
154
  const tools = await createTools(session);
129
155
  const names = tools.map((t) => t.name);
@@ -153,6 +179,7 @@ describe("createTools", () => {
153
179
  const expectedTools = [
154
180
  "ask",
155
181
  "bash",
182
+ "python",
156
183
  "calc",
157
184
  "ssh",
158
185
  "edit",