@oh-my-pi/pi-coding-agent 6.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [6.7.67] - 2026-01-19
6
+ ### Added
7
+
8
+ - Added normative rewrite setting to control tool call argument normalization in session history
9
+ - Added read line numbers setting to prepend line numbers to read tool output by default
10
+ - Added streaming preview for edit and write tools with spinner animation
11
+ - Added automatic anchor derivation for normative patches when anchors not specified
12
+
13
+ ### Changed
14
+
15
+ - Enhanced edit and write tool renderers to show streaming content preview
16
+ - Updated read tool to respect default line numbers setting
17
+ - Improved normative patch anchor handling to support undefined anchors
18
+
5
19
  ## [6.7.0] - 2026-01-19
6
20
 
7
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "6.7.0",
3
+ "version": "6.7.67",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,10 +40,10 @@
40
40
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
41
41
  },
42
42
  "dependencies": {
43
- "@oh-my-pi/pi-agent-core": "6.7.0",
44
- "@oh-my-pi/pi-ai": "6.7.0",
45
- "@oh-my-pi/pi-git-tool": "6.7.0",
46
- "@oh-my-pi/pi-tui": "6.7.0",
43
+ "@oh-my-pi/pi-agent-core": "6.7.67",
44
+ "@oh-my-pi/pi-ai": "6.7.67",
45
+ "@oh-my-pi/pi-git-tool": "6.7.67",
46
+ "@oh-my-pi/pi-tui": "6.7.67",
47
47
  "@openai/agents": "^0.3.7",
48
48
  "@sinclair/typebox": "^0.34.46",
49
49
  "ajv": "^8.17.1",
@@ -440,7 +440,7 @@ export class AgentSession {
440
440
  details?: unknown;
441
441
  $normative?: Record<string, unknown>;
442
442
  };
443
- if ($normative && toolCallId) {
443
+ if ($normative && toolCallId && this.settingsManager.getNormativeRewrite()) {
444
444
  await this._rewriteToolCallArgs(toolCallId, $normative);
445
445
  }
446
446
  }
@@ -236,6 +236,8 @@ export interface Settings {
236
236
  disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
237
237
  statusLine?: StatusLineSettings; // Status line configuration
238
238
  showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
239
+ normativeRewrite?: boolean; // default: false (rewrite tool call arguments to normalized format in session history)
240
+ readLineNumbers?: boolean; // default: false (prepend line numbers to read tool output by default)
239
241
  }
240
242
 
241
243
  export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
@@ -1299,6 +1301,24 @@ export class SettingsManager {
1299
1301
  await this.save();
1300
1302
  }
1301
1303
 
1304
+ getNormativeRewrite(): boolean {
1305
+ return this.settings.normativeRewrite ?? false;
1306
+ }
1307
+
1308
+ async setNormativeRewrite(enabled: boolean): Promise<void> {
1309
+ this.globalSettings.normativeRewrite = enabled;
1310
+ await this.save();
1311
+ }
1312
+
1313
+ getReadLineNumbers(): boolean {
1314
+ return this.settings.readLineNumbers ?? false;
1315
+ }
1316
+
1317
+ async setReadLineNumbers(enabled: boolean): Promise<void> {
1318
+ this.globalSettings.readLineNumbers = enabled;
1319
+ await this.save();
1320
+ }
1321
+
1302
1322
  getDisabledProviders(): string[] {
1303
1323
  return [...(this.settings.disabledProviders ?? [])];
1304
1324
  }
@@ -125,6 +125,7 @@ export interface ToolSession {
125
125
  /** Settings manager (optional) */
126
126
  settings?: {
127
127
  getImageAutoResize(): boolean;
128
+ getReadLineNumbers?(): boolean;
128
129
  getLspFormatOnWrite(): boolean;
129
130
  getLspDiagnosticsOnWrite(): boolean;
130
131
  getLspDiagnosticsOnEdit(): boolean;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { generateUnifiedDiffString } from "./diff";
6
6
  import { normalizeToLF, stripBom } from "./normalize";
7
+ import { parseHunks } from "./parser";
7
8
  import type { PatchInput } from "./types";
8
9
 
9
10
  export interface NormativePatchOptions {
@@ -17,7 +18,7 @@ export interface NormativePatchOptions {
17
18
 
18
19
  /** Normative patch input is the MongoDB-style update variant */
19
20
 
20
- function applyAnchors(diff: string, anchors: string[] | undefined): string {
21
+ function applyAnchors(diff: string, anchors: Array<string | undefined> | undefined): string {
21
22
  if (!anchors || anchors.length === 0) {
22
23
  return diff;
23
24
  }
@@ -28,17 +29,40 @@ function applyAnchors(diff: string, anchors: string[] | undefined): string {
28
29
  const anchor = anchors[anchorIndex];
29
30
  if (anchor !== undefined) {
30
31
  lines[i] = anchor.trim().length === 0 ? "@@" : `@@ ${anchor}`;
31
- anchorIndex++;
32
32
  }
33
+ anchorIndex++;
33
34
  }
34
35
  return lines.join("\n");
35
36
  }
36
37
 
38
+ function deriveAnchors(diff: string): Array<string | undefined> {
39
+ const hunks = parseHunks(diff);
40
+ return hunks.map((hunk) => {
41
+ if (hunk.oldLines.length === 0 || hunk.newLines.length === 0) {
42
+ return undefined;
43
+ }
44
+ const newLines = new Set(hunk.newLines);
45
+ for (const line of hunk.oldLines) {
46
+ const trimmed = line.trim();
47
+ if (trimmed.length === 0) continue;
48
+ if (!/[A-Za-z0-9_]/.test(trimmed)) continue;
49
+ if (newLines.has(line)) {
50
+ return trimmed;
51
+ }
52
+ }
53
+ return undefined;
54
+ });
55
+ }
56
+
37
57
  export function buildNormativeUpdateInput(options: NormativePatchOptions): PatchInput {
38
58
  const normalizedOld = normalizeToLF(stripBom(options.oldContent).text);
39
59
  const normalizedNew = normalizeToLF(stripBom(options.newContent).text);
40
60
  const diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, options.contextLines ?? 3);
41
- const anchors = typeof options.anchor === "string" ? [options.anchor] : options.anchor;
61
+ let anchors: Array<string | undefined> | undefined =
62
+ typeof options.anchor === "string" ? [options.anchor] : options.anchor;
63
+ if (!anchors) {
64
+ anchors = deriveAnchors(diffResult.diff);
65
+ }
42
66
  const diff = applyAnchors(diffResult.diff, anchors);
43
67
  return {
44
68
  path: options.path,
@@ -5,17 +5,20 @@
5
5
  import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
6
6
  import type { Component } from "@oh-my-pi/pi-tui";
7
7
  import { Text } from "@oh-my-pi/pi-tui";
8
+ import { renderDiff as renderDiffColored } from "../../../modes/interactive/components/diff";
8
9
  import { getLanguageFromPath, type Theme } from "../../../modes/interactive/theme/theme";
9
10
  import type { RenderResultOptions } from "../../custom-tools/types";
10
11
  import type { FileDiagnosticsResult } from "../lsp/index";
11
12
  import {
12
13
  createToolUIKit,
13
14
  formatExpandHint,
15
+ formatStatusIcon,
14
16
  getDiffStats,
15
17
  shortenPath,
16
18
  type ToolUIKit,
17
19
  truncateDiffByHunk,
18
20
  } from "../render-utils";
21
+ import type { RenderCallOptions } from "../renderers";
19
22
  import type { DiffError, DiffResult, Operation } from "./types";
20
23
 
21
24
  // ═══════════════════════════════════════════════════════════════════════════
@@ -82,12 +85,29 @@ export interface EditRenderContext {
82
85
 
83
86
  const EDIT_DIFF_PREVIEW_HUNKS = 2;
84
87
  const EDIT_DIFF_PREVIEW_LINES = 24;
88
+ const EDIT_STREAMING_PREVIEW_LINES = 12;
85
89
 
86
90
  function countLines(text: string): number {
87
91
  if (!text) return 0;
88
92
  return text.split("\n").length;
89
93
  }
90
94
 
95
+ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme): string {
96
+ if (!diff) return "";
97
+ const lines = diff.split("\n");
98
+ const total = lines.length;
99
+ const displayLines = lines.slice(-EDIT_STREAMING_PREVIEW_LINES);
100
+ const hidden = total - displayLines.length;
101
+
102
+ let text = "\n\n";
103
+ if (hidden > 0) {
104
+ text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)\n`);
105
+ }
106
+ text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath });
107
+ text += uiTheme.fg("dim", `\n${uiTheme.format.ellipsis} (streaming)`);
108
+ return text;
109
+ }
110
+
91
111
  function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
92
112
  const icon = uiTheme.getLangIcon(language);
93
113
  if (lineCount !== null) {
@@ -136,7 +156,7 @@ function renderDiffSection(
136
156
  export const editToolRenderer = {
137
157
  mergeCallAndResult: true,
138
158
 
139
- renderCall(args: EditRenderArgs, uiTheme: Theme): Component {
159
+ renderCall(args: EditRenderArgs, uiTheme: Theme, options?: RenderCallOptions): Component {
140
160
  const ui = createToolUIKit(uiTheme);
141
161
  const rawPath = args.file_path || args.path || "";
142
162
  const filePath = shortenPath(rawPath);
@@ -151,7 +171,16 @@ export const editToolRenderer = {
151
171
 
152
172
  // Show operation type for patch mode
153
173
  const opTitle = args.op === "create" ? "Create" : args.op === "delete" ? "Delete" : "Edit";
154
- const text = `${ui.title(opTitle)} ${editIcon} ${pathDisplay}`;
174
+ const spinner =
175
+ options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
176
+ let text = `${ui.title(opTitle)} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`;
177
+
178
+ // Show streaming preview of diff/content
179
+ const streamingContent = args.diff ?? args.newText ?? args.patch;
180
+ if (streamingContent) {
181
+ text += formatStreamingDiff(streamingContent, rawPath, uiTheme);
182
+ }
183
+
155
184
  return new Text(text, 0, 0);
156
185
  },
157
186
 
@@ -441,11 +441,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
441
441
 
442
442
  private readonly session: ToolSession;
443
443
  private readonly autoResizeImages: boolean;
444
+ private readonly defaultLineNumbers: boolean;
444
445
  private readonly lsTool: LsTool;
445
446
 
446
447
  constructor(session: ToolSession) {
447
448
  this.session = session;
448
449
  this.autoResizeImages = session.settings?.getImageAutoResize() ?? true;
450
+ this.defaultLineNumbers = session.settings?.getReadLineNumbers?.() ?? false;
449
451
  this.lsTool = new LsTool(session);
450
452
  this.description = renderPromptTemplate(readDescription, {
451
453
  DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
@@ -622,8 +624,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
622
624
  // Apply truncation (respects both line and byte limits)
623
625
  const truncation = truncateHead(selectedContent);
624
626
 
625
- // Add line numbers if requested (default: false)
626
- const shouldAddLineNumbers = lines === true;
627
+ // Add line numbers if requested (uses setting default if not specified)
628
+ const shouldAddLineNumbers = lines ?? this.defaultLineNumbers;
627
629
  const prependLineNumbers = (text: string, startNum: number): string => {
628
630
  const textLines = text.split("\n");
629
631
  const lastLineNum = startNum + textLines.length - 1;
@@ -26,8 +26,12 @@ import { webFetchToolRenderer } from "./web-fetch";
26
26
  import { webSearchToolRenderer } from "./web-search/render";
27
27
  import { writeToolRenderer } from "./write";
28
28
 
29
+ export interface RenderCallOptions {
30
+ spinnerFrame?: number;
31
+ }
32
+
29
33
  type ToolRenderer = {
30
- renderCall: (args: unknown, theme: Theme) => Component;
34
+ renderCall: (args: unknown, theme: Theme, options?: RenderCallOptions) => Component;
31
35
  renderResult: (
32
36
  result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
33
37
  options: RenderResultOptions & { renderContext?: Record<string, unknown> },
@@ -21,7 +21,8 @@ import {
21
21
  writethroughNoop,
22
22
  } from "./lsp/index";
23
23
  import { resolveToCwd } from "./path-utils";
24
- import { formatDiagnostics, formatExpandHint, replaceTabs, shortenPath } from "./render-utils";
24
+ import { formatDiagnostics, formatExpandHint, formatStatusIcon, replaceTabs, shortenPath } from "./render-utils";
25
+ import type { RenderCallOptions } from "./renderers";
25
26
 
26
27
  const writeSchema = Type.Object({
27
28
  path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
@@ -124,11 +125,34 @@ interface WriteRenderArgs {
124
125
  content?: string;
125
126
  }
126
127
 
128
+ const WRITE_STREAMING_PREVIEW_LINES = 12;
129
+
127
130
  function countLines(text: string): number {
128
131
  if (!text) return 0;
129
132
  return text.split("\n").length;
130
133
  }
131
134
 
135
+ function formatStreamingContent(content: string, rawPath: string, uiTheme: Theme): string {
136
+ if (!content) return "";
137
+ const lang = getLanguageFromPath(rawPath);
138
+ const lines = content.split("\n");
139
+ const total = lines.length;
140
+ const displayLines = lines.slice(-WRITE_STREAMING_PREVIEW_LINES);
141
+ const hidden = total - displayLines.length;
142
+
143
+ const formattedLines = lang
144
+ ? highlightCode(replaceTabs(displayLines.join("\n")), lang)
145
+ : displayLines.map((line: string) => uiTheme.fg("toolOutput", replaceTabs(line)));
146
+
147
+ let text = "\n\n";
148
+ if (hidden > 0) {
149
+ text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)\n`);
150
+ }
151
+ text += formattedLines.join("\n");
152
+ text += uiTheme.fg("dim", `\n${uiTheme.format.ellipsis} (streaming)`);
153
+ return text;
154
+ }
155
+
132
156
  function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
133
157
  const icon = uiTheme.getLangIcon(language);
134
158
  if (lineCount !== null) {
@@ -138,11 +162,19 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin
138
162
  }
139
163
 
140
164
  export const writeToolRenderer = {
141
- renderCall(args: WriteRenderArgs, uiTheme: Theme): Component {
165
+ renderCall(args: WriteRenderArgs, uiTheme: Theme, options?: RenderCallOptions): Component {
142
166
  const rawPath = args.file_path || args.path || "";
143
167
  const filePath = shortenPath(rawPath);
144
168
  const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
145
- const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Write"))} ${pathDisplay}`;
169
+ const spinner =
170
+ options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
171
+ let text = `${uiTheme.fg("toolTitle", uiTheme.bold("Write"))} ${spinner ? `${spinner} ` : ""}${pathDisplay}`;
172
+
173
+ // Show streaming preview of content
174
+ if (args.content) {
175
+ text += formatStreamingContent(args.content, rawPath, uiTheme);
176
+ }
177
+
146
178
  return new Text(text, 0, 0);
147
179
  },
148
180
 
@@ -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",
@@ -312,6 +321,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
312
321
  get: (sm) => sm.getEditPatchMode(),
313
322
  set: (sm, v) => sm.setEditPatchMode(v),
314
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
+ },
315
333
  {
316
334
  id: "mcpProjectConfig",
317
335
  tab: "tools",
@@ -135,6 +135,8 @@ export class ToolExecutionComponent extends Container {
135
135
  // Spinner animation for partial task results
136
136
  private spinnerFrame = 0;
137
137
  private spinnerInterval: ReturnType<typeof setInterval> | null = null;
138
+ // Track if args are still being streamed (for edit/write spinner)
139
+ private argsComplete = false;
138
140
 
139
141
  constructor(
140
142
  toolName: string,
@@ -175,6 +177,7 @@ export class ToolExecutionComponent extends Container {
175
177
 
176
178
  updateArgs(args: any, _toolCallId?: string): void {
177
179
  this.args = args;
180
+ this.updateSpinnerAnimation();
178
181
  this.updateDisplay();
179
182
  }
180
183
 
@@ -183,6 +186,8 @@ export class ToolExecutionComponent extends Container {
183
186
  * This triggers diff computation for edit tool.
184
187
  */
185
188
  setArgsComplete(_toolCallId?: string): void {
189
+ this.argsComplete = true;
190
+ this.updateSpinnerAnimation();
186
191
  this.maybeComputeEditDiff();
187
192
  }
188
193
 
@@ -311,7 +316,10 @@ export class ToolExecutionComponent extends Container {
311
316
  * Start or stop spinner animation based on whether this is a partial task result.
312
317
  */
313
318
  private updateSpinnerAnimation(): void {
314
- const needsSpinner = this.isPartial && this.toolName === "task";
319
+ // Spinner for: task tool with partial result, or edit/write while args streaming
320
+ const isStreamingArgs = !this.argsComplete && (this.toolName === "edit" || this.toolName === "write");
321
+ const isPartialTask = this.isPartial && this.toolName === "task";
322
+ const needsSpinner = isStreamingArgs || isPartialTask;
315
323
  if (needsSpinner && !this.spinnerInterval) {
316
324
  this.spinnerInterval = setInterval(() => {
317
325
  const frameCount = theme.spinnerFrames.length;
@@ -428,7 +436,9 @@ export class ToolExecutionComponent extends Container {
428
436
  if (shouldRenderCall) {
429
437
  // Render call component
430
438
  try {
431
- const callComponent = renderer.renderCall(this.args, theme);
439
+ const callComponent = renderer.renderCall(this.args, theme, {
440
+ spinnerFrame: this.spinnerFrame,
441
+ });
432
442
  if (callComponent) {
433
443
  // Ensure component has invalidate() method for Component interface
434
444
  const component = callComponent as any;