@oh-my-pi/pi-coding-agent 6.8.5 → 6.9.69

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 (155) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/package.json +6 -6
  3. package/src/cli/stats-cli.ts +191 -0
  4. package/src/core/agent-session.ts +103 -1
  5. package/src/core/extensions/index.ts +2 -0
  6. package/src/core/extensions/runner.ts +31 -0
  7. package/src/core/extensions/types.ts +24 -0
  8. package/src/core/messages.ts +48 -0
  9. package/src/core/sdk.ts +0 -2
  10. package/src/core/session-manager.ts +10 -1
  11. package/src/core/settings-manager.ts +0 -105
  12. package/src/core/tools/bash.ts +5 -7
  13. package/src/core/tools/index.ts +1 -5
  14. package/src/core/tools/patch/applicator.ts +115 -17
  15. package/src/core/tools/patch/index.ts +1 -1
  16. package/src/core/tools/patch/normalize.ts +185 -10
  17. package/src/core/tools/python.ts +444 -86
  18. package/src/core/tools/task/executor.ts +2 -6
  19. package/src/core/tools/task/index.ts +30 -12
  20. package/src/core/tools/task/render.ts +163 -30
  21. package/src/core/tools/task/template.ts +37 -0
  22. package/src/core/tools/task/types.ts +6 -2
  23. package/src/core/tools/task/worker.ts +1 -1
  24. package/src/index.ts +2 -2
  25. package/src/main.ts +12 -0
  26. package/src/modes/interactive/components/python-execution.ts +180 -0
  27. package/src/modes/interactive/components/settings-defs.ts +0 -70
  28. package/src/modes/interactive/components/settings-selector.ts +0 -1
  29. package/src/modes/interactive/components/welcome.ts +1 -0
  30. package/src/modes/interactive/controllers/command-controller.ts +46 -0
  31. package/src/modes/interactive/controllers/event-controller.ts +0 -11
  32. package/src/modes/interactive/controllers/input-controller.ts +28 -1
  33. package/src/modes/interactive/controllers/selector-controller.ts +0 -9
  34. package/src/modes/interactive/interactive-mode.ts +10 -58
  35. package/src/modes/interactive/theme/dark.json +2 -9
  36. package/src/modes/interactive/theme/defaults/alabaster.json +2 -8
  37. package/src/modes/interactive/theme/defaults/amethyst.json +2 -9
  38. package/src/modes/interactive/theme/defaults/anthracite.json +2 -9
  39. package/src/modes/interactive/theme/defaults/basalt.json +89 -88
  40. package/src/modes/interactive/theme/defaults/birch.json +2 -8
  41. package/src/modes/interactive/theme/defaults/dark-abyss.json +2 -8
  42. package/src/modes/interactive/theme/defaults/dark-arctic.json +2 -9
  43. package/src/modes/interactive/theme/defaults/dark-aurora.json +3 -2
  44. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +2 -1
  45. package/src/modes/interactive/theme/defaults/dark-cavern.json +2 -8
  46. package/src/modes/interactive/theme/defaults/dark-copper.json +3 -2
  47. package/src/modes/interactive/theme/defaults/dark-cosmos.json +2 -8
  48. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +2 -9
  49. package/src/modes/interactive/theme/defaults/dark-dracula.json +2 -9
  50. package/src/modes/interactive/theme/defaults/dark-eclipse.json +2 -8
  51. package/src/modes/interactive/theme/defaults/dark-ember.json +3 -2
  52. package/src/modes/interactive/theme/defaults/dark-equinox.json +2 -8
  53. package/src/modes/interactive/theme/defaults/dark-forest.json +2 -9
  54. package/src/modes/interactive/theme/defaults/dark-github.json +2 -9
  55. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +2 -9
  56. package/src/modes/interactive/theme/defaults/dark-lavender.json +3 -2
  57. package/src/modes/interactive/theme/defaults/dark-lunar.json +2 -8
  58. package/src/modes/interactive/theme/defaults/dark-midnight.json +3 -2
  59. package/src/modes/interactive/theme/defaults/dark-monochrome.json +2 -9
  60. package/src/modes/interactive/theme/defaults/dark-monokai.json +2 -9
  61. package/src/modes/interactive/theme/defaults/dark-nebula.json +2 -8
  62. package/src/modes/interactive/theme/defaults/dark-nord.json +2 -9
  63. package/src/modes/interactive/theme/defaults/dark-ocean.json +2 -9
  64. package/src/modes/interactive/theme/defaults/dark-one.json +2 -9
  65. package/src/modes/interactive/theme/defaults/dark-rainforest.json +2 -8
  66. package/src/modes/interactive/theme/defaults/dark-reef.json +2 -8
  67. package/src/modes/interactive/theme/defaults/dark-retro.json +2 -9
  68. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +2 -1
  69. package/src/modes/interactive/theme/defaults/dark-sakura.json +3 -2
  70. package/src/modes/interactive/theme/defaults/dark-slate.json +3 -2
  71. package/src/modes/interactive/theme/defaults/dark-solarized.json +2 -1
  72. package/src/modes/interactive/theme/defaults/dark-solstice.json +2 -8
  73. package/src/modes/interactive/theme/defaults/dark-starfall.json +2 -8
  74. package/src/modes/interactive/theme/defaults/dark-sunset.json +2 -9
  75. package/src/modes/interactive/theme/defaults/dark-swamp.json +2 -8
  76. package/src/modes/interactive/theme/defaults/dark-synthwave.json +2 -1
  77. package/src/modes/interactive/theme/defaults/dark-taiga.json +2 -8
  78. package/src/modes/interactive/theme/defaults/dark-terminal.json +3 -2
  79. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +2 -9
  80. package/src/modes/interactive/theme/defaults/dark-tundra.json +2 -8
  81. package/src/modes/interactive/theme/defaults/dark-twilight.json +2 -8
  82. package/src/modes/interactive/theme/defaults/dark-volcanic.json +2 -8
  83. package/src/modes/interactive/theme/defaults/graphite.json +2 -9
  84. package/src/modes/interactive/theme/defaults/light-arctic.json +2 -1
  85. package/src/modes/interactive/theme/defaults/light-aurora-day.json +2 -8
  86. package/src/modes/interactive/theme/defaults/light-canyon.json +2 -8
  87. package/src/modes/interactive/theme/defaults/light-catppuccin.json +2 -1
  88. package/src/modes/interactive/theme/defaults/light-cirrus.json +2 -8
  89. package/src/modes/interactive/theme/defaults/light-coral.json +3 -2
  90. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +2 -9
  91. package/src/modes/interactive/theme/defaults/light-dawn.json +2 -8
  92. package/src/modes/interactive/theme/defaults/light-dunes.json +2 -8
  93. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +3 -2
  94. package/src/modes/interactive/theme/defaults/light-forest.json +2 -9
  95. package/src/modes/interactive/theme/defaults/light-frost.json +3 -2
  96. package/src/modes/interactive/theme/defaults/light-github.json +2 -1
  97. package/src/modes/interactive/theme/defaults/light-glacier.json +2 -8
  98. package/src/modes/interactive/theme/defaults/light-gruvbox.json +2 -9
  99. package/src/modes/interactive/theme/defaults/light-haze.json +2 -8
  100. package/src/modes/interactive/theme/defaults/light-honeycomb.json +3 -2
  101. package/src/modes/interactive/theme/defaults/light-lagoon.json +2 -8
  102. package/src/modes/interactive/theme/defaults/light-lavender.json +3 -2
  103. package/src/modes/interactive/theme/defaults/light-meadow.json +2 -8
  104. package/src/modes/interactive/theme/defaults/light-mint.json +3 -2
  105. package/src/modes/interactive/theme/defaults/light-monochrome.json +2 -1
  106. package/src/modes/interactive/theme/defaults/light-ocean.json +2 -9
  107. package/src/modes/interactive/theme/defaults/light-one.json +2 -8
  108. package/src/modes/interactive/theme/defaults/light-opal.json +2 -8
  109. package/src/modes/interactive/theme/defaults/light-orchard.json +2 -8
  110. package/src/modes/interactive/theme/defaults/light-paper.json +3 -2
  111. package/src/modes/interactive/theme/defaults/light-prism.json +2 -8
  112. package/src/modes/interactive/theme/defaults/light-retro.json +2 -9
  113. package/src/modes/interactive/theme/defaults/light-sand.json +3 -2
  114. package/src/modes/interactive/theme/defaults/light-savanna.json +2 -8
  115. package/src/modes/interactive/theme/defaults/light-solarized.json +2 -1
  116. package/src/modes/interactive/theme/defaults/light-soleil.json +2 -8
  117. package/src/modes/interactive/theme/defaults/light-sunset.json +2 -9
  118. package/src/modes/interactive/theme/defaults/light-synthwave.json +2 -9
  119. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +2 -9
  120. package/src/modes/interactive/theme/defaults/light-wetland.json +2 -8
  121. package/src/modes/interactive/theme/defaults/light-zenith.json +2 -8
  122. package/src/modes/interactive/theme/defaults/limestone.json +2 -8
  123. package/src/modes/interactive/theme/defaults/mahogany.json +2 -9
  124. package/src/modes/interactive/theme/defaults/marble.json +2 -8
  125. package/src/modes/interactive/theme/defaults/obsidian.json +89 -88
  126. package/src/modes/interactive/theme/defaults/onyx.json +89 -88
  127. package/src/modes/interactive/theme/defaults/pearl.json +2 -8
  128. package/src/modes/interactive/theme/defaults/porcelain.json +89 -88
  129. package/src/modes/interactive/theme/defaults/quartz.json +2 -8
  130. package/src/modes/interactive/theme/defaults/sandstone.json +2 -8
  131. package/src/modes/interactive/theme/defaults/titanium.json +88 -87
  132. package/src/modes/interactive/theme/light.json +2 -8
  133. package/src/modes/interactive/theme/theme-schema.json +5 -0
  134. package/src/modes/interactive/theme/theme.ts +7 -0
  135. package/src/modes/interactive/types.ts +5 -15
  136. package/src/modes/interactive/utils/ui-helpers.ts +20 -0
  137. package/src/prompts/system/system-prompt.md +8 -0
  138. package/src/prompts/tools/python.md +40 -2
  139. package/src/prompts/tools/task.md +8 -13
  140. package/src/core/custom-commands/bundled/wt/index.ts +0 -435
  141. package/src/core/tools/git.ts +0 -213
  142. package/src/core/voice-controller.ts +0 -135
  143. package/src/core/voice-supervisor.ts +0 -976
  144. package/src/core/voice.ts +0 -314
  145. package/src/lib/worktree/collapse.ts +0 -180
  146. package/src/lib/worktree/constants.ts +0 -14
  147. package/src/lib/worktree/errors.ts +0 -23
  148. package/src/lib/worktree/git.ts +0 -60
  149. package/src/lib/worktree/index.ts +0 -15
  150. package/src/lib/worktree/operations.ts +0 -216
  151. package/src/lib/worktree/session.ts +0 -114
  152. package/src/lib/worktree/stats.ts +0 -67
  153. package/src/modes/interactive/utils/voice-manager.ts +0 -96
  154. package/src/prompts/tools/git.md +0 -9
  155. package/src/prompts/voice-summary.md +0 -12
@@ -98,10 +98,6 @@ export interface BashInterceptorSettings {
98
98
  patterns?: BashInterceptorRule[]; // default: built-in rules
99
99
  }
100
100
 
101
- export interface GitSettings {
102
- enabled?: boolean; // default: false (structured git tool; use bash for git commands when disabled)
103
- }
104
-
105
101
  export interface MCPSettings {
106
102
  enableProjectConfig?: boolean; // default: true (load .mcp.json from project root)
107
103
  }
@@ -145,15 +141,6 @@ export interface TodoCompletionSettings {
145
141
  maxReminders?: number; // default: 3 - maximum reminders before giving up
146
142
  }
147
143
 
148
- export interface VoiceSettings {
149
- enabled?: boolean; // default: false
150
- transcriptionModel?: string; // default: "whisper-1"
151
- transcriptionLanguage?: string; // optional language hint (e.g., "en")
152
- ttsModel?: string; // default: "gpt-4o-mini-tts"
153
- ttsVoice?: string; // default: "alloy"
154
- ttsFormat?: "wav" | "mp3" | "opus" | "aac" | "flac"; // default: "wav"
155
- }
156
-
157
144
  export type StatusLineSegmentId =
158
145
  | "pi"
159
146
  | "model"
@@ -224,14 +211,12 @@ export interface Settings {
224
211
  enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
225
212
  exa?: ExaSettings;
226
213
  bashInterceptor?: BashInterceptorSettings;
227
- git?: GitSettings;
228
214
  mcp?: MCPSettings;
229
215
  lsp?: LspSettings;
230
216
  python?: PythonSettings;
231
217
  edit?: EditSettings;
232
218
  ttsr?: TtsrSettings;
233
219
  todoCompletion?: TodoCompletionSettings;
234
- voice?: VoiceSettings;
235
220
  providers?: ProviderSettings;
236
221
  disabledProviders?: string[]; // Discovery provider IDs that are disabled
237
222
  disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
@@ -252,12 +237,6 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
252
237
  tool: "grep",
253
238
  message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
254
239
  },
255
- {
256
- pattern: "^\\s*git(\\s+|$)",
257
- tool: "git",
258
- message:
259
- "Use the `git` tool instead of running git in bash. It provides structured output and safety confirmations.",
260
- },
261
240
  {
262
241
  pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
263
242
  tool: "find",
@@ -319,19 +298,11 @@ const DEFAULT_SETTINGS: Settings = {
319
298
  enableWebsets: false,
320
299
  },
321
300
  bashInterceptor: DEFAULT_BASH_INTERCEPTOR_SETTINGS,
322
- git: { enabled: false },
323
301
  mcp: { enableProjectConfig: true },
324
302
  lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false },
325
303
  python: { toolMode: "both", kernelMode: "session", sharedGateway: true },
326
304
  edit: { fuzzyMatch: true, fuzzyThreshold: 0.95, streamingAbort: false },
327
305
  ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 },
328
- voice: {
329
- enabled: false,
330
- transcriptionModel: "whisper-1",
331
- ttsModel: "gpt-4o-mini-tts",
332
- ttsVoice: "alloy",
333
- ttsFormat: "wav",
334
- },
335
306
  providers: { webSearch: "auto", image: "auto" },
336
307
  } satisfies Settings;
337
308
 
@@ -1170,10 +1141,6 @@ export class SettingsManager {
1170
1141
  await this.save();
1171
1142
  }
1172
1143
 
1173
- getGitToolEnabled(): boolean {
1174
- return this.settings.git?.enabled ?? false;
1175
- }
1176
-
1177
1144
  getPythonToolMode(): PythonToolMode {
1178
1145
  return this.settings.python?.toolMode ?? "both";
1179
1146
  }
@@ -1210,14 +1177,6 @@ export class SettingsManager {
1210
1177
  await this.save();
1211
1178
  }
1212
1179
 
1213
- async setGitToolEnabled(enabled: boolean): Promise<void> {
1214
- if (!this.globalSettings.git) {
1215
- this.globalSettings.git = {};
1216
- }
1217
- this.globalSettings.git.enabled = enabled;
1218
- await this.save();
1219
- }
1220
-
1221
1180
  getMCPProjectConfigEnabled(): boolean {
1222
1181
  return this.settings.mcp?.enableProjectConfig ?? true;
1223
1182
  }
@@ -1430,70 +1389,6 @@ export class SettingsManager {
1430
1389
  await this.save();
1431
1390
  }
1432
1391
 
1433
- getVoiceSettings(): Required<VoiceSettings> {
1434
- return {
1435
- enabled: this.settings.voice?.enabled ?? false,
1436
- transcriptionModel: this.settings.voice?.transcriptionModel ?? "whisper-1",
1437
- transcriptionLanguage: this.settings.voice?.transcriptionLanguage ?? "",
1438
- ttsModel: this.settings.voice?.ttsModel ?? "tts-1",
1439
- ttsVoice: this.settings.voice?.ttsVoice ?? "alloy",
1440
- ttsFormat: this.settings.voice?.ttsFormat ?? "wav",
1441
- };
1442
- }
1443
-
1444
- async setVoiceSettings(settings: VoiceSettings): Promise<void> {
1445
- this.globalSettings.voice = { ...this.globalSettings.voice, ...settings };
1446
- await this.save();
1447
- }
1448
-
1449
- getVoiceEnabled(): boolean {
1450
- return this.settings.voice?.enabled ?? false;
1451
- }
1452
-
1453
- async setVoiceEnabled(enabled: boolean): Promise<void> {
1454
- if (!this.globalSettings.voice) {
1455
- this.globalSettings.voice = {};
1456
- }
1457
- this.globalSettings.voice.enabled = enabled;
1458
- await this.save();
1459
- }
1460
-
1461
- getVoiceTtsModel(): string {
1462
- return this.settings.voice?.ttsModel ?? "gpt-4o-mini-tts";
1463
- }
1464
-
1465
- async setVoiceTtsModel(model: string): Promise<void> {
1466
- if (!this.globalSettings.voice) {
1467
- this.globalSettings.voice = {};
1468
- }
1469
- this.globalSettings.voice.ttsModel = model;
1470
- await this.save();
1471
- }
1472
-
1473
- getVoiceTtsVoice(): string {
1474
- return this.settings.voice?.ttsVoice ?? "alloy";
1475
- }
1476
-
1477
- async setVoiceTtsVoice(voice: string): Promise<void> {
1478
- if (!this.globalSettings.voice) {
1479
- this.globalSettings.voice = {};
1480
- }
1481
- this.globalSettings.voice.ttsVoice = voice;
1482
- await this.save();
1483
- }
1484
-
1485
- getVoiceTtsFormat(): "wav" | "mp3" | "opus" | "aac" | "flac" {
1486
- return this.settings.voice?.ttsFormat ?? "wav";
1487
- }
1488
-
1489
- async setVoiceTtsFormat(format: "wav" | "mp3" | "opus" | "aac" | "flac"): Promise<void> {
1490
- if (!this.globalSettings.voice) {
1491
- this.globalSettings.voice = {};
1492
- }
1493
- this.globalSettings.voice.ttsFormat = format;
1494
- await this.save();
1495
- }
1496
-
1497
1392
  // ═══════════════════════════════════════════════════════════════════════════
1498
1393
  // Status Line Settings
1499
1394
  // ═══════════════════════════════════════════════════════════════════════════
@@ -20,9 +20,7 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
20
20
  const bashSchema = Type.Object({
21
21
  command: Type.String({ description: "Bash command to execute" }),
22
22
  timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
23
- workdir: Type.Optional(
24
- Type.String({ description: "Working directory for the command (default: current directory)" }),
25
- ),
23
+ cwd: Type.Optional(Type.String({ description: "Working directory for the command (default: current directory)" })),
26
24
  });
27
25
 
28
26
  export interface BashToolDetails {
@@ -53,7 +51,7 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
53
51
 
54
52
  public async execute(
55
53
  _toolCallId: string,
56
- { command, timeout, workdir }: { command: string; timeout?: number; workdir?: string },
54
+ { command, timeout, cwd }: { command: string; timeout?: number; cwd?: string },
57
55
  signal?: AbortSignal,
58
56
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
59
57
  ctx?: AgentToolContext,
@@ -73,7 +71,7 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
73
71
  }
74
72
  }
75
73
 
76
- const commandCwd = workdir ? resolveToCwd(workdir, this.session.cwd) : this.session.cwd;
74
+ const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
77
75
  let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
78
76
  try {
79
77
  cwdStat = await Bun.file(commandCwd).stat();
@@ -143,7 +141,7 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
143
141
  interface BashRenderArgs {
144
142
  command?: string;
145
143
  timeout?: number;
146
- workdir?: string;
144
+ cwd?: string;
147
145
  }
148
146
 
149
147
  interface BashRenderContext {
@@ -166,7 +164,7 @@ export const bashToolRenderer = {
166
164
  const command = args.command || uiTheme.format.ellipsis;
167
165
  const prompt = uiTheme.fg("accent", "$");
168
166
  const cwd = process.cwd();
169
- let displayWorkdir = args.workdir;
167
+ let displayWorkdir = args.cwd;
170
168
 
171
169
  if (displayWorkdir) {
172
170
  const resolvedCwd = resolve(cwd);
@@ -7,7 +7,6 @@ export { exaTools } from "./exa/index";
7
7
  export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types";
8
8
  export { type FindOperations, FindTool, type FindToolDetails, type FindToolOptions } from "./find";
9
9
  export { setPreferredImageProvider } from "./gemini-image";
10
- export { GitTool, type GitToolDetails } from "./git";
11
10
  export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolOptions } from "./grep";
12
11
  export { type LsOperations, LsTool, type LsToolDetails, type LsToolOptions } from "./ls";
13
12
  export {
@@ -72,7 +71,6 @@ import { BashTool } from "./bash";
72
71
  import { CalculatorTool } from "./calculator";
73
72
  import { CompleteTool } from "./complete";
74
73
  import { FindTool } from "./find";
75
- import { GitTool } from "./git";
76
74
  import { GrepTool } from "./grep";
77
75
  import { LsTool } from "./ls";
78
76
  import { LspTool } from "./lsp/index";
@@ -132,7 +130,6 @@ export interface ToolSession {
132
130
  getEditFuzzyMatch(): boolean;
133
131
  getEditFuzzyThreshold?(): number;
134
132
  getEditPatchMode?(): boolean;
135
- getGitToolEnabled(): boolean;
136
133
  getBashInterceptorEnabled(): boolean;
137
134
  getBashInterceptorSimpleLsEnabled(): boolean;
138
135
  getBashInterceptorRules(): BashInterceptorRule[];
@@ -152,7 +149,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
152
149
  ssh: loadSshTool,
153
150
  edit: (s) => new EditTool(s),
154
151
  find: (s) => new FindTool(s),
155
- git: GitTool.createIf,
156
152
  grep: (s) => new GrepTool(s),
157
153
  ls: (s) => new LsTool(s),
158
154
  lsp: LspTool.createIf,
@@ -225,7 +221,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
225
221
  });
226
222
  } else if (!isTestEnv && getPreludeDocs().length === 0) {
227
223
  const sessionFile = session.getSessionFile?.() ?? undefined;
228
- const warmSessionId = sessionFile ? `session:${sessionFile}:workdir:${session.cwd}` : `cwd:${session.cwd}`;
224
+ const warmSessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
229
225
  void warmPythonEnvironment(session.cwd, warmSessionId, session.settings?.getPythonSharedGateway?.()).catch(
230
226
  (err) => {
231
227
  logger.warn("Failed to warm Python environment", {
@@ -11,6 +11,7 @@ import { resolveToCwd } from "../path-utils";
11
11
  import { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch, seekSequence } from "./fuzzy";
12
12
  import {
13
13
  adjustIndentation,
14
+ convertLeadingTabsToSpaces,
14
15
  countLeadingWhitespace,
15
16
  detectLineEnding,
16
17
  getLeadingWhitespace,
@@ -82,6 +83,34 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
82
83
  return newLines;
83
84
  }
84
85
 
86
+ // If pattern already matches actual exactly (including indentation), preserve agent's intended changes
87
+ if (patternLines.length === actualLines.length) {
88
+ let exactMatch = true;
89
+ for (let i = 0; i < patternLines.length; i++) {
90
+ if (patternLines[i] !== actualLines[i]) {
91
+ exactMatch = false;
92
+ break;
93
+ }
94
+ }
95
+ if (exactMatch) {
96
+ return newLines;
97
+ }
98
+ }
99
+
100
+ // If the patch is purely an indentation change (same trimmed content), apply exactly as specified
101
+ if (patternLines.length === newLines.length) {
102
+ let indentationOnly = true;
103
+ for (let i = 0; i < patternLines.length; i++) {
104
+ if (patternLines[i].trim() !== newLines[i].trim()) {
105
+ indentationOnly = false;
106
+ break;
107
+ }
108
+ }
109
+ if (indentationOnly) {
110
+ return newLines;
111
+ }
112
+ }
113
+
85
114
  // Detect indent character from actual content
86
115
  let indentChar = " ";
87
116
  for (const line of actualLines) {
@@ -92,6 +121,55 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
92
121
  }
93
122
  }
94
123
 
124
+ let patternTabOnly = true;
125
+ let actualSpaceOnly = true;
126
+ let patternMixed = false;
127
+ let actualMixed = false;
128
+
129
+ for (const line of patternLines) {
130
+ if (line.trim().length === 0) continue;
131
+ const ws = getLeadingWhitespace(line);
132
+ if (ws.includes(" ")) patternTabOnly = false;
133
+ if (ws.includes(" ") && ws.includes("\t")) patternMixed = true;
134
+ }
135
+
136
+ for (const line of actualLines) {
137
+ if (line.trim().length === 0) continue;
138
+ const ws = getLeadingWhitespace(line);
139
+ if (ws.includes("\t")) actualSpaceOnly = false;
140
+ if (ws.includes(" ") && ws.includes("\t")) actualMixed = true;
141
+ }
142
+
143
+ if (!patternMixed && !actualMixed && patternTabOnly && actualSpaceOnly) {
144
+ let ratio: number | undefined;
145
+ const lineCount = Math.min(patternLines.length, actualLines.length);
146
+ let consistent = true;
147
+ for (let i = 0; i < lineCount; i++) {
148
+ const patternLine = patternLines[i];
149
+ const actualLine = actualLines[i];
150
+ if (patternLine.trim().length === 0 || actualLine.trim().length === 0) continue;
151
+ const patternIndent = countLeadingWhitespace(patternLine);
152
+ const actualIndent = countLeadingWhitespace(actualLine);
153
+ if (patternIndent === 0) continue;
154
+ if (actualIndent % patternIndent !== 0) {
155
+ consistent = false;
156
+ break;
157
+ }
158
+ const nextRatio = actualIndent / patternIndent;
159
+ if (!ratio) {
160
+ ratio = nextRatio;
161
+ } else if (ratio !== nextRatio) {
162
+ consistent = false;
163
+ break;
164
+ }
165
+ }
166
+
167
+ if (consistent && ratio) {
168
+ const converted = convertLeadingTabsToSpaces(newLines.join("\n"), ratio).split("\n");
169
+ return converted;
170
+ }
171
+ }
172
+
95
173
  // Build a map from trimmed content to actual lines (by content, not position)
96
174
  // This handles fuzzy matches where pattern and actual may not be positionally aligned
97
175
  const contentToActualLines = new Map<string, string[]>();
@@ -106,18 +184,29 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
106
184
  }
107
185
  }
108
186
 
109
- // Compute fallback delta from all non-empty lines (for truly new lines)
110
- let totalDelta = 0;
111
- let deltaCount = 0;
187
+ let patternMin = Infinity;
188
+ for (const line of patternLines) {
189
+ if (line.trim().length === 0) continue;
190
+ patternMin = Math.min(patternMin, countLeadingWhitespace(line));
191
+ }
192
+ if (patternMin === Infinity) {
193
+ patternMin = 0;
194
+ }
195
+
196
+ let delta: number | undefined;
197
+ const deltas: number[] = [];
112
198
  for (let i = 0; i < Math.min(patternLines.length, actualLines.length); i++) {
113
- if (patternLines[i].trim().length > 0 && actualLines[i].trim().length > 0) {
114
- const pIndent = countLeadingWhitespace(patternLines[i]);
115
- const aIndent = countLeadingWhitespace(actualLines[i]);
116
- totalDelta += aIndent - pIndent;
117
- deltaCount++;
118
- }
199
+ const patternLine = patternLines[i];
200
+ const actualLine = actualLines[i];
201
+ if (patternLine.trim().length === 0 || actualLine.trim().length === 0) continue;
202
+ const pIndent = countLeadingWhitespace(patternLine);
203
+ const aIndent = countLeadingWhitespace(actualLine);
204
+ deltas.push(aIndent - pIndent);
205
+ }
206
+
207
+ if (deltas.length > 0 && deltas.every((value) => value === deltas[0])) {
208
+ delta = deltas[0];
119
209
  }
120
- const avgDelta = deltaCount > 0 ? Math.round(totalDelta / deltaCount) : 0;
121
210
 
122
211
  // Track which actual lines we've used to handle duplicate content correctly
123
212
  const usedActualLines = new Map<string, number>(); // trimmed content -> count used
@@ -132,6 +221,12 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
132
221
 
133
222
  // Check if this is a context line (same trimmed content exists in actual)
134
223
  if (matchingActualLines && matchingActualLines.length > 0) {
224
+ if (matchingActualLines.length === 1) {
225
+ return matchingActualLines[0];
226
+ }
227
+ if (matchingActualLines.includes(newLine)) {
228
+ return newLine;
229
+ }
135
230
  const usedCount = usedActualLines.get(trimmed) ?? 0;
136
231
  if (usedCount < matchingActualLines.length) {
137
232
  usedActualLines.set(trimmed, usedCount + 1);
@@ -140,13 +235,16 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
140
235
  }
141
236
  }
142
237
 
143
- // This is a new/added line - apply average delta
144
- if (avgDelta > 0) {
145
- return indentChar.repeat(avgDelta) + newLine;
146
- }
147
- if (avgDelta < 0) {
148
- const toRemove = Math.min(-avgDelta, countLeadingWhitespace(newLine));
149
- return newLine.slice(toRemove);
238
+ // This is a new/added line - apply consistent delta if safe
239
+ if (delta && delta !== 0) {
240
+ const newIndent = countLeadingWhitespace(newLine);
241
+ if (newIndent === patternMin) {
242
+ if (delta > 0) {
243
+ return indentChar.repeat(delta) + newLine;
244
+ }
245
+ const toRemove = Math.min(-delta, newIndent);
246
+ return newLine.slice(toRemove);
247
+ }
150
248
  }
151
249
  return newLine;
152
250
  });
@@ -48,7 +48,7 @@ export { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch as findEditMatch, f
48
48
 
49
49
  // Normalization
50
50
  export {
51
- adjustIndentation as adjustNewTextIndentation,
51
+ adjustIndentation,
52
52
  detectLineEnding,
53
53
  normalizeToLF,
54
54
  restoreLineEndings,
@@ -92,6 +92,114 @@ export function detectIndentChar(text: string): string {
92
92
  return " ";
93
93
  }
94
94
 
95
+ function gcd(a: number, b: number): number {
96
+ let x = Math.abs(a);
97
+ let y = Math.abs(b);
98
+ while (y !== 0) {
99
+ const temp = y;
100
+ y = x % y;
101
+ x = temp;
102
+ }
103
+ return x;
104
+ }
105
+
106
+ interface IndentProfile {
107
+ lines: string[];
108
+ indentStrings: string[];
109
+ indentCounts: number[];
110
+ min: number;
111
+ char: " " | "\t" | undefined;
112
+ spaceOnly: boolean;
113
+ tabOnly: boolean;
114
+ mixed: boolean;
115
+ unit: number;
116
+ nonEmptyCount: number;
117
+ }
118
+
119
+ function buildIndentProfile(text: string): IndentProfile {
120
+ const lines = text.split("\n");
121
+ const indentStrings: string[] = [];
122
+ const indentCounts: number[] = [];
123
+ let min = Infinity;
124
+ let char: " " | "\t" | undefined;
125
+ let spaceOnly = true;
126
+ let tabOnly = true;
127
+ let mixed = false;
128
+ let nonEmptyCount = 0;
129
+ let unit = 0;
130
+
131
+ for (const line of lines) {
132
+ if (line.trim().length === 0) continue;
133
+ nonEmptyCount++;
134
+ const indent = getLeadingWhitespace(line);
135
+ indentStrings.push(indent);
136
+ indentCounts.push(indent.length);
137
+ min = Math.min(min, indent.length);
138
+ if (indent.includes(" ")) {
139
+ tabOnly = false;
140
+ }
141
+ if (indent.includes("\t")) {
142
+ spaceOnly = false;
143
+ }
144
+ if (indent.includes(" ") && indent.includes("\t")) {
145
+ mixed = true;
146
+ }
147
+ if (indent.length > 0) {
148
+ const currentChar = indent[0] as " " | "\t";
149
+ if (!char) {
150
+ char = currentChar;
151
+ } else if (char !== currentChar) {
152
+ mixed = true;
153
+ }
154
+ }
155
+ }
156
+
157
+ if (min === Infinity) {
158
+ min = 0;
159
+ }
160
+
161
+ if (spaceOnly && nonEmptyCount > 0) {
162
+ let current = 0;
163
+ for (const count of indentCounts) {
164
+ if (count === 0) continue;
165
+ current = current === 0 ? count : gcd(current, count);
166
+ }
167
+ unit = current;
168
+ }
169
+
170
+ if (tabOnly && nonEmptyCount > 0) {
171
+ unit = 1;
172
+ }
173
+
174
+ return {
175
+ lines,
176
+ indentStrings,
177
+ indentCounts,
178
+ min,
179
+ char,
180
+ spaceOnly,
181
+ tabOnly,
182
+ mixed,
183
+ unit,
184
+ nonEmptyCount,
185
+ };
186
+ }
187
+
188
+ export function convertLeadingTabsToSpaces(text: string, spacesPerTab: number): string {
189
+ if (spacesPerTab <= 0) return text;
190
+ return text
191
+ .split("\n")
192
+ .map((line) => {
193
+ const trimmed = line.trimStart();
194
+ if (trimmed.length === 0) return line;
195
+ const leading = getLeadingWhitespace(line);
196
+ if (!leading.includes("\t") || leading.includes(" ")) return line;
197
+ const converted = " ".repeat(leading.length * spacesPerTab);
198
+ return converted + trimmed;
199
+ })
200
+ .join("\n");
201
+ }
202
+
95
203
  // ═══════════════════════════════════════════════════════════════════════════
96
204
  // Unicode Normalization
97
205
  // ═══════════════════════════════════════════════════════════════════════════
@@ -191,27 +299,94 @@ export function normalizeForFuzzy(line: string): string {
191
299
  * to each line in newText.
192
300
  */
193
301
  export function adjustIndentation(oldText: string, actualText: string, newText: string): string {
194
- const oldMin = minIndent(oldText);
195
- const actualMin = minIndent(actualText);
196
- const delta = actualMin - oldMin;
302
+ // If old text already matches actual text exactly, preserve agent's intended indentation
303
+ if (oldText === actualText) {
304
+ return newText;
305
+ }
306
+
307
+ // If the patch is purely an indentation change (same trimmed content), apply exactly as specified
308
+ const oldLines = oldText.split("\n");
309
+ const newLines = newText.split("\n");
310
+ if (oldLines.length === newLines.length) {
311
+ let indentationOnly = true;
312
+ for (let i = 0; i < oldLines.length; i++) {
313
+ if (oldLines[i].trim() !== newLines[i].trim()) {
314
+ indentationOnly = false;
315
+ break;
316
+ }
317
+ }
318
+ if (indentationOnly) {
319
+ return newText;
320
+ }
321
+ }
322
+
323
+ const oldProfile = buildIndentProfile(oldText);
324
+ const actualProfile = buildIndentProfile(actualText);
325
+ const newProfile = buildIndentProfile(newText);
326
+
327
+ if (newProfile.nonEmptyCount === 0 || oldProfile.nonEmptyCount === 0 || actualProfile.nonEmptyCount === 0) {
328
+ return newText;
329
+ }
330
+
331
+ if (oldProfile.mixed || actualProfile.mixed || newProfile.mixed) {
332
+ return newText;
333
+ }
334
+
335
+ if (oldProfile.char && actualProfile.char && oldProfile.char !== actualProfile.char) {
336
+ if (actualProfile.spaceOnly && oldProfile.tabOnly && newProfile.tabOnly && actualProfile.unit > 0) {
337
+ let consistent = true;
338
+ const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
339
+ for (let i = 0; i < lineCount; i++) {
340
+ const oldLine = oldProfile.lines[i];
341
+ const actualLine = actualProfile.lines[i];
342
+ if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
343
+ const oldIndent = getLeadingWhitespace(oldLine);
344
+ const actualIndent = getLeadingWhitespace(actualLine);
345
+ if (oldIndent.length === 0) continue;
346
+ if (actualIndent.length !== oldIndent.length * actualProfile.unit) {
347
+ consistent = false;
348
+ break;
349
+ }
350
+ }
351
+ return consistent ? convertLeadingTabsToSpaces(newText, actualProfile.unit) : newText;
352
+ }
353
+ return newText;
354
+ }
355
+
356
+ const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
357
+ const deltas: number[] = [];
358
+ for (let i = 0; i < lineCount; i++) {
359
+ const oldLine = oldProfile.lines[i];
360
+ const actualLine = actualProfile.lines[i];
361
+ if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
362
+ deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine));
363
+ }
364
+
365
+ if (deltas.length === 0) {
366
+ return newText;
367
+ }
368
+
369
+ const delta = deltas[0];
370
+ if (!deltas.every((value) => value === delta)) {
371
+ return newText;
372
+ }
197
373
 
198
374
  if (delta === 0) {
199
375
  return newText;
200
376
  }
201
377
 
202
- const indentChar = detectIndentChar(actualText);
203
- const lines = newText.split("\n");
378
+ if (newProfile.char && actualProfile.char && newProfile.char !== actualProfile.char) {
379
+ return newText;
380
+ }
204
381
 
205
- const adjusted = lines.map((line) => {
382
+ const indentChar = actualProfile.char ?? oldProfile.char ?? detectIndentChar(actualText);
383
+ const adjusted = newText.split("\n").map((line) => {
206
384
  if (line.trim().length === 0) {
207
- return line; // Preserve empty/whitespace-only lines as-is
385
+ return line;
208
386
  }
209
-
210
387
  if (delta > 0) {
211
388
  return indentChar.repeat(delta) + line;
212
389
  }
213
-
214
- // Remove indentation (delta < 0)
215
390
  const toRemove = Math.min(-delta, countLeadingWhitespace(line));
216
391
  return line.slice(toRemove);
217
392
  });