@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.67

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 (93) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +54 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +22 -24
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +63 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +604 -578
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +73 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +257 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +239 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +6 -2
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +108 -47
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +42 -0
  83. package/src/modes/interactive/components/tool-execution.ts +46 -8
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -345
package/src/core/ttsr.ts CHANGED
@@ -21,44 +21,6 @@ interface InjectionRecord {
21
21
  lastInjectedAt: number;
22
22
  }
23
23
 
24
- export interface TtsrManager {
25
- /** Add a TTSR rule to be monitored */
26
- addRule(rule: Rule): void;
27
-
28
- /** Check if any uninjected TTSR matches the stream buffer. Returns matching rules. */
29
- check(streamBuffer: string): Rule[];
30
-
31
- /** Mark rules as injected (won't trigger again until conditions allow) */
32
- markInjected(rules: Rule[]): void;
33
-
34
- /** Get names of all injected rules (for persistence) */
35
- getInjectedRuleNames(): string[];
36
-
37
- /** Restore injected state from a list of rule names */
38
- restoreInjected(ruleNames: string[]): void;
39
-
40
- /** Reset stream buffer (called on new turn) */
41
- resetBuffer(): void;
42
-
43
- /** Get current stream buffer */
44
- getBuffer(): string;
45
-
46
- /** Append to stream buffer */
47
- appendToBuffer(text: string): void;
48
-
49
- /** Check if any TTSRs are registered */
50
- hasRules(): boolean;
51
-
52
- /** Increment message counter (call after each turn) */
53
- incrementMessageCount(): void;
54
-
55
- /** Get current message count */
56
- getMessageCount(): number;
57
-
58
- /** Get settings */
59
- getSettings(): Required<TtsrSettings>;
60
- }
61
-
62
24
  const DEFAULT_SETTINGS: Required<TtsrSettings> = {
63
25
  enabled: true,
64
26
  contextMode: "discard",
@@ -66,146 +28,138 @@ const DEFAULT_SETTINGS: Required<TtsrSettings> = {
66
28
  repeatGap: 10,
67
29
  };
68
30
 
69
- export function createTtsrManager(settings?: TtsrSettings): TtsrManager {
70
- /** Resolved settings with defaults */
71
- const resolvedSettings: Required<TtsrSettings> = {
72
- ...DEFAULT_SETTINGS,
73
- ...settings,
74
- };
75
-
76
- /** Map of rule name -> { rule, compiled regex } */
77
- const rules = new Map<string, TtsrEntry>();
78
-
79
- /** Map of rule name -> injection record */
80
- const injectionRecords = new Map<string, InjectionRecord>();
31
+ export class TtsrManager {
32
+ private readonly settings: Required<TtsrSettings>;
33
+ private readonly rules = new Map<string, TtsrEntry>();
34
+ private readonly injectionRecords = new Map<string, InjectionRecord>();
35
+ private buffer = "";
36
+ private messageCount = 0;
81
37
 
82
- /** Current stream buffer for pattern matching */
83
- let buffer = "";
84
-
85
- /** Message counter for tracking gap between injections */
86
- let messageCount = 0;
38
+ constructor(settings?: TtsrSettings) {
39
+ this.settings = { ...DEFAULT_SETTINGS, ...settings };
40
+ }
87
41
 
88
42
  /** Check if a rule can be triggered based on repeat settings */
89
- function canTrigger(ruleName: string): boolean {
90
- const record = injectionRecords.get(ruleName);
43
+ private canTrigger(ruleName: string): boolean {
44
+ const record = this.injectionRecords.get(ruleName);
91
45
  if (!record) {
92
- // Never injected, can trigger
93
46
  return true;
94
47
  }
95
48
 
96
- if (resolvedSettings.repeatMode === "once") {
97
- // Once mode: never trigger again after first injection
49
+ if (this.settings.repeatMode === "once") {
98
50
  return false;
99
51
  }
100
52
 
101
- // After-gap mode: check if enough messages have passed
102
- const gap = messageCount - record.lastInjectedAt;
103
- return gap >= resolvedSettings.repeatGap;
53
+ const gap = this.messageCount - record.lastInjectedAt;
54
+ return gap >= this.settings.repeatGap;
104
55
  }
105
56
 
106
- return {
107
- addRule(rule: Rule): void {
108
- // Only add rules that have a TTSR trigger pattern
109
- if (!rule.ttsrTrigger) {
110
- return;
111
- }
57
+ /** Add a TTSR rule to be monitored */
58
+ addRule(rule: Rule): void {
59
+ if (!rule.ttsrTrigger) {
60
+ return;
61
+ }
62
+
63
+ if (this.rules.has(rule.name)) {
64
+ return;
65
+ }
66
+
67
+ try {
68
+ const regex = new RegExp(rule.ttsrTrigger);
69
+ this.rules.set(rule.name, { rule, regex });
70
+ logger.debug("TTSR rule registered", {
71
+ ruleName: rule.name,
72
+ pattern: rule.ttsrTrigger,
73
+ });
74
+ } catch (err) {
75
+ logger.warn("TTSR rule has invalid regex pattern, skipping", {
76
+ ruleName: rule.name,
77
+ pattern: rule.ttsrTrigger,
78
+ error: err instanceof Error ? err.message : String(err),
79
+ });
80
+ }
81
+ }
112
82
 
113
- // Skip if already registered
114
- if (rules.has(rule.name)) {
115
- return;
83
+ /** Check if any uninjected TTSR matches the stream buffer. Returns matching rules. */
84
+ check(streamBuffer: string): Rule[] {
85
+ const matches: Rule[] = [];
86
+
87
+ for (const [name, entry] of this.rules) {
88
+ if (!this.canTrigger(name)) {
89
+ continue;
116
90
  }
117
91
 
118
- // Compile the regex pattern
119
- try {
120
- const regex = new RegExp(rule.ttsrTrigger);
121
- rules.set(rule.name, { rule, regex });
122
- logger.debug("TTSR rule registered", {
123
- ruleName: rule.name,
124
- pattern: rule.ttsrTrigger,
125
- });
126
- } catch (err) {
127
- logger.warn("TTSR rule has invalid regex pattern, skipping", {
128
- ruleName: rule.name,
129
- pattern: rule.ttsrTrigger,
130
- error: err instanceof Error ? err.message : String(err),
92
+ if (entry.regex.test(streamBuffer)) {
93
+ matches.push(entry.rule);
94
+ logger.debug("TTSR pattern matched", {
95
+ ruleName: name,
96
+ pattern: entry.rule.ttsrTrigger,
131
97
  });
132
98
  }
133
- },
134
-
135
- check(streamBuffer: string): Rule[] {
136
- const matches: Rule[] = [];
137
-
138
- for (const [name, entry] of rules) {
139
- // Skip rules that can't trigger yet
140
- if (!canTrigger(name)) {
141
- continue;
142
- }
143
-
144
- // Test the buffer against the rule's pattern
145
- if (entry.regex.test(streamBuffer)) {
146
- matches.push(entry.rule);
147
- logger.debug("TTSR pattern matched", {
148
- ruleName: name,
149
- pattern: entry.rule.ttsrTrigger,
150
- });
151
- }
152
- }
99
+ }
153
100
 
154
- return matches;
155
- },
101
+ return matches;
102
+ }
156
103
 
157
- markInjected(rulesToMark: Rule[]): void {
158
- for (const rule of rulesToMark) {
159
- injectionRecords.set(rule.name, { lastInjectedAt: messageCount });
160
- logger.debug("TTSR rule marked as injected", {
161
- ruleName: rule.name,
162
- messageCount,
163
- repeatMode: resolvedSettings.repeatMode,
164
- });
165
- }
166
- },
104
+ /** Mark rules as injected (won't trigger again until conditions allow) */
105
+ markInjected(rulesToMark: Rule[]): void {
106
+ for (const rule of rulesToMark) {
107
+ this.injectionRecords.set(rule.name, { lastInjectedAt: this.messageCount });
108
+ logger.debug("TTSR rule marked as injected", {
109
+ ruleName: rule.name,
110
+ messageCount: this.messageCount,
111
+ repeatMode: this.settings.repeatMode,
112
+ });
113
+ }
114
+ }
167
115
 
168
- getInjectedRuleNames(): string[] {
169
- return Array.from(injectionRecords.keys());
170
- },
116
+ /** Get names of all injected rules (for persistence) */
117
+ getInjectedRuleNames(): string[] {
118
+ return Array.from(this.injectionRecords.keys());
119
+ }
171
120
 
172
- restoreInjected(ruleNames: string[]): void {
173
- // When restoring, we don't know the original message count, so use 0
174
- // This means in "after-gap" mode, rules can trigger again after the gap
175
- for (const name of ruleNames) {
176
- injectionRecords.set(name, { lastInjectedAt: 0 });
177
- }
178
- if (ruleNames.length > 0) {
179
- logger.debug("TTSR injected state restored", { ruleNames });
180
- }
181
- },
121
+ /** Restore injected state from a list of rule names */
122
+ restoreInjected(ruleNames: string[]): void {
123
+ for (const name of ruleNames) {
124
+ this.injectionRecords.set(name, { lastInjectedAt: 0 });
125
+ }
126
+ if (ruleNames.length > 0) {
127
+ logger.debug("TTSR injected state restored", { ruleNames });
128
+ }
129
+ }
182
130
 
183
- resetBuffer(): void {
184
- buffer = "";
185
- },
131
+ /** Reset stream buffer (called on new turn) */
132
+ resetBuffer(): void {
133
+ this.buffer = "";
134
+ }
186
135
 
187
- getBuffer(): string {
188
- return buffer;
189
- },
136
+ /** Get current stream buffer */
137
+ getBuffer(): string {
138
+ return this.buffer;
139
+ }
190
140
 
191
- appendToBuffer(text: string): void {
192
- buffer += text;
193
- },
141
+ /** Append to stream buffer */
142
+ appendToBuffer(text: string): void {
143
+ this.buffer += text;
144
+ }
194
145
 
195
- hasRules(): boolean {
196
- return rules.size > 0;
197
- },
146
+ /** Check if any TTSRs are registered */
147
+ hasRules(): boolean {
148
+ return this.rules.size > 0;
149
+ }
198
150
 
199
- incrementMessageCount(): void {
200
- messageCount++;
201
- },
151
+ /** Increment message counter (call after each turn) */
152
+ incrementMessageCount(): void {
153
+ this.messageCount++;
154
+ }
202
155
 
203
- getMessageCount(): number {
204
- return messageCount;
205
- },
156
+ /** Get current message count */
157
+ getMessageCount(): number {
158
+ return this.messageCount;
159
+ }
206
160
 
207
- getSettings(): Required<TtsrSettings> {
208
- return resolvedSettings;
209
- },
210
- };
161
+ /** Get settings */
162
+ getSettings(): Required<TtsrSettings> {
163
+ return this.settings;
164
+ }
211
165
  }
package/src/core/voice.ts CHANGED
@@ -23,6 +23,50 @@ export interface VoiceRecordingHandle {
23
23
  cleanup: () => void;
24
24
  }
25
25
 
26
+ export class VoiceRecording implements VoiceRecordingHandle {
27
+ readonly filePath: string;
28
+ private proc: ReturnType<typeof Bun.spawn>;
29
+
30
+ constructor(_settings: VoiceSettings) {
31
+ const sampleRate = DEFAULT_SAMPLE_RATE;
32
+ const channels = DEFAULT_CHANNELS;
33
+ this.filePath = join(tmpdir(), `omp-voice-${nanoid()}.wav`);
34
+ const command = buildRecordingCommand(this.filePath, sampleRate, channels);
35
+ if (!command) {
36
+ throw new Error("No audio recorder found (install sox, arecord, or ffmpeg).");
37
+ }
38
+
39
+ logger.debug("voice: starting recorder", { command });
40
+ this.proc = Bun.spawn(command, {
41
+ stdin: "ignore",
42
+ stdout: "ignore",
43
+ stderr: "pipe",
44
+ });
45
+ }
46
+
47
+ async stop(): Promise<void> {
48
+ try {
49
+ this.proc.kill();
50
+ } catch {
51
+ // ignore
52
+ }
53
+ await this.proc.exited;
54
+ }
55
+
56
+ cleanup(): void {
57
+ try {
58
+ unlinkSync(this.filePath);
59
+ } catch {
60
+ // ignore cleanup errors
61
+ }
62
+ }
63
+
64
+ async cancel(): Promise<void> {
65
+ await this.stop();
66
+ this.cleanup();
67
+ }
68
+ }
69
+
26
70
  export interface VoiceTranscriptionResult {
27
71
  text: string;
28
72
  }
@@ -99,45 +143,11 @@ function buildRecordingCommand(filePath: string, sampleRate: number, channels: n
99
143
  return null;
100
144
  }
101
145
 
102
- export async function startVoiceRecording(_settings: VoiceSettings): Promise<VoiceRecordingHandle> {
103
- const sampleRate = DEFAULT_SAMPLE_RATE;
104
- const channels = DEFAULT_CHANNELS;
105
- const filePath = join(tmpdir(), `omp-voice-${nanoid()}.wav`);
106
- const command = buildRecordingCommand(filePath, sampleRate, channels);
107
- if (!command) {
108
- throw new Error("No audio recorder found (install sox, arecord, or ffmpeg).");
109
- }
110
-
111
- logger.debug("voice: starting recorder", { command });
112
- const proc = Bun.spawn(command, {
113
- stdin: "ignore",
114
- stdout: "ignore",
115
- stderr: "pipe",
116
- });
117
-
118
- const stop = async (): Promise<void> => {
119
- try {
120
- proc.kill();
121
- } catch {
122
- // ignore
123
- }
124
- await proc.exited;
125
- };
126
-
127
- const cleanup = (): void => {
128
- try {
129
- unlinkSync(filePath);
130
- } catch {
131
- // ignore cleanup errors
132
- }
133
- };
134
-
135
- const cancel = async (): Promise<void> => {
136
- await stop();
137
- cleanup();
138
- };
139
-
140
- return { filePath, stop, cancel, cleanup };
146
+ /**
147
+ * @deprecated Use `new VoiceRecording(settings)` instead.
148
+ */
149
+ export function startVoiceRecording(settings: VoiceSettings): VoiceRecordingHandle {
150
+ return new VoiceRecording(settings);
141
151
  }
142
152
 
143
153
  export async function transcribeAudio(
package/src/index.ts CHANGED
@@ -63,7 +63,7 @@ export type {
63
63
  LoadedCustomTool,
64
64
  RenderResultOptions,
65
65
  } from "./core/custom-tools/index";
66
- export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index";
66
+ export { CustomToolLoader, discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index";
67
67
  // Extension types and utilities
68
68
  export type {
69
69
  AppAction,
@@ -79,7 +79,6 @@ export type {
79
79
  ExtensionFactory,
80
80
  ExtensionFlag,
81
81
  ExtensionHandler,
82
- ExtensionRuntime,
83
82
  ExtensionShortcut,
84
83
  ExtensionUIContext,
85
84
  ExtensionUIDialogOptions,
@@ -97,9 +96,9 @@ export type {
97
96
  UserBashEventResult,
98
97
  } from "./core/extensions/index";
99
98
  export {
100
- createExtensionRuntime,
101
99
  discoverAndLoadExtensions,
102
100
  ExtensionRunner,
101
+ ExtensionRuntime,
103
102
  isBashToolResult,
104
103
  isEditToolResult,
105
104
  isFindToolResult,
@@ -119,22 +118,16 @@ export { ModelRegistry } from "./core/model-registry";
119
118
  export type { PromptTemplate } from "./core/prompt-templates";
120
119
  // SDK for programmatic usage
121
120
  export {
121
+ // Factory
122
+ BashTool,
122
123
  // Tool factories
123
124
  BUILTIN_TOOLS,
124
125
  type BuildSystemPromptOptions,
125
126
  buildSystemPrompt,
126
127
  type CreateAgentSessionOptions,
127
128
  type CreateAgentSessionResult,
128
- // Factory
129
129
  createAgentSession,
130
- createBashTool,
131
- createEditTool,
132
- createFindTool,
133
- createGrepTool,
134
- createLsTool,
135
- createReadTool,
136
130
  createTools,
137
- createWriteTool,
138
131
  // Discovery
139
132
  discoverAuthStorage,
140
133
  discoverContextFiles,
@@ -144,8 +137,16 @@ export {
144
137
  discoverModels,
145
138
  discoverPromptTemplates,
146
139
  discoverSkills,
140
+ EditTool,
141
+ FindTool,
142
+ GrepTool,
143
+ LsTool,
147
144
  loadSettings,
145
+ loadSshTool,
146
+ PythonTool,
147
+ ReadTool,
148
148
  type ToolSession,
149
+ WriteTool,
149
150
  } from "./core/sdk";
150
151
  export {
151
152
  type BranchSummaryEntry,
@@ -201,11 +202,11 @@ export {
201
202
  type FindToolDetails,
202
203
  type FindToolOptions,
203
204
  formatSize,
205
+ GitTool,
204
206
  type GitToolDetails,
205
207
  type GrepOperations,
206
208
  type GrepToolDetails,
207
209
  type GrepToolOptions,
208
- gitTool,
209
210
  type LsOperations,
210
211
  type LsToolDetails,
211
212
  type LsToolOptions,
@@ -226,6 +227,9 @@ export {
226
227
  InteractiveMode,
227
228
  type InteractiveModeOptions,
228
229
  type PrintModeOptions,
230
+ RpcClient,
231
+ type RpcClientOptions,
232
+ type RpcEventListener,
229
233
  runPrintMode,
230
234
  runRpcMode,
231
235
  } from "./modes/index";
@@ -2,15 +2,7 @@ export { type CollapseOptions, type CollapseResult, type CollapseStrategy, colla
2
2
  export { WORKTREE_BASE } from "./constants";
3
3
  export { WorktreeError, WorktreeErrorCode } from "./errors";
4
4
  export { getRepoName, getRepoRoot, git, gitWithStdin } from "./git";
5
- export {
6
- create,
7
- find,
8
- list,
9
- prune,
10
- remove,
11
- type Worktree,
12
- which,
13
- } from "./operations";
5
+ export { create, find, list, prune, remove, type Worktree, which } from "./operations";
14
6
  export {
15
7
  cleanupSessions,
16
8
  createSession,
@@ -6,9 +6,9 @@ import { theme } from "../theme/theme";
6
6
  * Format: "+123 content" or "-123 content" or " 123 content" or " ..."
7
7
  */
8
8
  function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null {
9
- const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
9
+ const match = line.match(/^([+-\s])(?:(\s*\d+)\s)?(.*)$/);
10
10
  if (!match) return null;
11
- return { prefix: match[1], lineNum: match[2], content: match[3] };
11
+ return { prefix: match[1], lineNum: match[2] ?? "", content: match[3] };
12
12
  }
13
13
 
14
14
  /**
@@ -80,6 +80,13 @@ export function renderDiff(diffText: string, _options: RenderDiffOptions = {}):
80
80
  const lines = diffText.split("\n");
81
81
  const result: string[] = [];
82
82
 
83
+ const formatLine = (prefix: string, lineNum: string, content: string): string => {
84
+ if (lineNum.trim().length === 0) {
85
+ return `${prefix}${content}`;
86
+ }
87
+ return `${prefix}${lineNum} ${content}`;
88
+ };
89
+
83
90
  let i = 0;
84
91
  while (i < lines.length) {
85
92
  const line = lines[i];
@@ -121,24 +128,24 @@ export function renderDiff(diffText: string, _options: RenderDiffOptions = {}):
121
128
  replaceTabs(added.content),
122
129
  );
123
130
 
124
- result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${removedLine}`));
125
- result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${addedLine}`));
131
+ result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, removedLine)));
132
+ result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, addedLine)));
126
133
  } else {
127
134
  // Show all removed lines first, then all added lines
128
135
  for (const removed of removedLines) {
129
- result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`));
136
+ result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, replaceTabs(removed.content))));
130
137
  }
131
138
  for (const added of addedLines) {
132
- result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`));
139
+ result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, replaceTabs(added.content))));
133
140
  }
134
141
  }
135
142
  } else if (parsed.prefix === "+") {
136
143
  // Standalone added line
137
- result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`));
144
+ result.push(theme.fg("toolDiffAdded", formatLine("+", parsed.lineNum, replaceTabs(parsed.content))));
138
145
  i++;
139
146
  } else {
140
147
  // Context line
141
- result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`));
148
+ result.push(theme.fg("toolDiffContext", formatLine(" ", parsed.lineNum, replaceTabs(parsed.content))));
142
149
  i++;
143
150
  }
144
151
  }
@@ -209,6 +209,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
209
209
  get: (sm) => sm.getCollapseChangelog(),
210
210
  set: (sm, v) => sm.setCollapseChangelog(v),
211
211
  },
212
+ {
213
+ id: "normativeRewrite",
214
+ tab: "behavior",
215
+ type: "boolean",
216
+ label: "Normative rewrite",
217
+ description: "Rewrite tool call arguments to normalized format in session history",
218
+ get: (sm) => sm.getNormativeRewrite(),
219
+ set: (sm, v) => sm.setNormativeRewrite(v),
220
+ },
212
221
  {
213
222
  id: "doubleEscapeAction",
214
223
  tab: "behavior",
@@ -288,6 +297,39 @@ export const SETTINGS_DEFS: SettingDef[] = [
288
297
  get: (sm) => sm.getEditFuzzyMatch(),
289
298
  set: (sm, v) => sm.setEditFuzzyMatch(v),
290
299
  },
300
+ {
301
+ id: "editFuzzyThreshold",
302
+ tab: "tools",
303
+ type: "submenu",
304
+ label: "Edit fuzzy threshold",
305
+ description: "Similarity threshold for fuzzy matches (higher = stricter)",
306
+ get: (sm) => sm.getEditFuzzyThreshold().toFixed(2),
307
+ set: (sm, v) => sm.setEditFuzzyThreshold(Number(v)),
308
+ getOptions: () => [
309
+ { value: "0.85", label: "0.85", description: "Lenient" },
310
+ { value: "0.90", label: "0.90", description: "Moderate" },
311
+ { value: "0.95", label: "0.95", description: "Default" },
312
+ { value: "0.98", label: "0.98", description: "Strict" },
313
+ ],
314
+ },
315
+ {
316
+ id: "editPatchMode",
317
+ tab: "tools",
318
+ type: "boolean",
319
+ label: "Edit patch mode",
320
+ description: "Use codex-style apply-patch format instead of oldText/newText for edits",
321
+ get: (sm) => sm.getEditPatchMode(),
322
+ set: (sm, v) => sm.setEditPatchMode(v),
323
+ },
324
+ {
325
+ id: "readLineNumbers",
326
+ tab: "tools",
327
+ type: "boolean",
328
+ label: "Read line numbers",
329
+ description: "Prepend line numbers to read tool output by default",
330
+ get: (sm) => sm.getReadLineNumbers(),
331
+ set: (sm, v) => sm.setReadLineNumbers(v),
332
+ },
291
333
  {
292
334
  id: "mcpProjectConfig",
293
335
  tab: "tools",