@oh-my-pi/pi-coding-agent 14.1.2 → 14.2.0

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 (123) hide show
  1. package/CHANGELOG.md +47 -2
  2. package/package.json +8 -8
  3. package/scripts/build-binary.ts +61 -0
  4. package/src/autoresearch/helpers.ts +10 -0
  5. package/src/autoresearch/index.ts +1 -11
  6. package/src/autoresearch/tools/init-experiment.ts +1 -10
  7. package/src/autoresearch/tools/log-experiment.ts +1 -11
  8. package/src/autoresearch/tools/run-experiment.ts +1 -10
  9. package/src/bun-imports.d.ts +6 -0
  10. package/src/cli/plugin-cli.ts +23 -45
  11. package/src/commit/agentic/tools/propose-commit.ts +1 -14
  12. package/src/commit/agentic/tools/split-commit.ts +1 -15
  13. package/src/commit/utils.ts +15 -1
  14. package/src/config/model-registry.ts +3 -3
  15. package/src/config/prompt-templates.ts +4 -12
  16. package/src/config/settings-schema.ts +27 -2
  17. package/src/config/settings.ts +1 -1
  18. package/src/discovery/claude-plugins.ts +61 -6
  19. package/src/discovery/codex.ts +2 -15
  20. package/src/discovery/gemini.ts +2 -15
  21. package/src/discovery/helpers.ts +40 -1
  22. package/src/discovery/opencode.ts +2 -15
  23. package/src/edit/apply-patch/index.ts +87 -0
  24. package/src/edit/apply-patch/parser.ts +174 -0
  25. package/src/edit/diff.ts +3 -14
  26. package/src/edit/index.ts +65 -2
  27. package/src/edit/modes/apply-patch.lark +19 -0
  28. package/src/edit/modes/apply-patch.ts +63 -0
  29. package/src/edit/modes/hashline.ts +3 -3
  30. package/src/edit/modes/replace.ts +2 -13
  31. package/src/edit/read-file.ts +18 -0
  32. package/src/edit/renderer.ts +61 -33
  33. package/src/extensibility/extensions/compact-handler.ts +40 -0
  34. package/src/extensibility/extensions/runner.ts +11 -29
  35. package/src/extensibility/utils.ts +7 -1
  36. package/src/internal-urls/docs-index.generated.ts +9 -2
  37. package/src/lsp/render.ts +14 -2
  38. package/src/main.ts +1 -0
  39. package/src/mcp/manager.ts +29 -48
  40. package/src/memories/index.ts +7 -1
  41. package/src/modes/acp/acp-agent.ts +3 -16
  42. package/src/modes/components/model-selector.ts +15 -24
  43. package/src/modes/components/plugin-settings.ts +16 -5
  44. package/src/modes/components/read-tool-group.ts +92 -9
  45. package/src/modes/components/settings-defs.ts +18 -0
  46. package/src/modes/components/settings-selector.ts +2 -6
  47. package/src/modes/components/tool-execution.ts +61 -28
  48. package/src/modes/controllers/event-controller.ts +3 -1
  49. package/src/modes/controllers/extension-ui-controller.ts +99 -150
  50. package/src/modes/controllers/selector-controller.ts +3 -12
  51. package/src/modes/interactive-mode.ts +4 -2
  52. package/src/modes/print-mode.ts +4 -22
  53. package/src/modes/rpc/rpc-mode.ts +18 -38
  54. package/src/modes/shared.ts +10 -1
  55. package/src/modes/utils/ui-helpers.ts +6 -2
  56. package/src/plan-mode/approved-plan.ts +5 -4
  57. package/src/prompts/system/subagent-system-prompt.md +4 -4
  58. package/src/prompts/system/subagent-user-prompt.md +2 -2
  59. package/src/prompts/system/system-prompt.md +208 -243
  60. package/src/prompts/tools/apply-patch.md +67 -0
  61. package/src/prompts/tools/ast-edit.md +18 -23
  62. package/src/prompts/tools/ast-grep.md +24 -32
  63. package/src/prompts/tools/bash.md +11 -23
  64. package/src/prompts/tools/debug.md +8 -22
  65. package/src/prompts/tools/find.md +0 -4
  66. package/src/prompts/tools/grep.md +3 -5
  67. package/src/prompts/tools/hashline.md +16 -10
  68. package/src/prompts/tools/python.md +10 -14
  69. package/src/prompts/tools/read.md +17 -24
  70. package/src/prompts/tools/task.md +57 -21
  71. package/src/prompts/tools/todo-write.md +45 -67
  72. package/src/session/agent-session.ts +4 -4
  73. package/src/session/session-manager.ts +15 -7
  74. package/src/session/streaming-output.ts +24 -0
  75. package/src/slash-commands/builtin-registry.ts +3 -14
  76. package/src/task/executor.ts +13 -34
  77. package/src/task/index.ts +82 -18
  78. package/src/task/simple-mode.ts +27 -0
  79. package/src/task/template.ts +17 -3
  80. package/src/task/types.ts +77 -30
  81. package/src/tools/ask.ts +2 -4
  82. package/src/tools/ast-edit.ts +4 -15
  83. package/src/tools/ast-grep.ts +8 -27
  84. package/src/tools/bash-skill-urls.ts +9 -7
  85. package/src/tools/bash.ts +4 -12
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/fetch.ts +1 -14
  88. package/src/tools/file-recorder.ts +35 -0
  89. package/src/tools/find.ts +6 -3
  90. package/src/tools/gh-format.ts +12 -0
  91. package/src/tools/gh-renderer.ts +1 -8
  92. package/src/tools/gh.ts +6 -13
  93. package/src/tools/grep.ts +9 -22
  94. package/src/tools/jtd-to-json-schema.ts +16 -0
  95. package/src/tools/match-line-format.ts +20 -0
  96. package/src/tools/path-utils.ts +30 -2
  97. package/src/tools/plan-mode-guard.ts +6 -5
  98. package/src/tools/python.ts +1 -1
  99. package/src/tools/read.ts +1 -1
  100. package/src/tools/render-utils.ts +38 -6
  101. package/src/tools/renderers.ts +1 -0
  102. package/src/tools/ssh.ts +3 -11
  103. package/src/tools/submit-result.ts +1 -13
  104. package/src/tools/todo-write.ts +137 -103
  105. package/src/tools/write.ts +2 -23
  106. package/src/tui/code-cell.ts +12 -7
  107. package/src/utils/edit-mode.ts +3 -2
  108. package/src/utils/git.ts +1 -1
  109. package/src/vim/engine.ts +41 -58
  110. package/src/web/scrapers/crates-io.ts +1 -14
  111. package/src/web/scrapers/types.ts +13 -0
  112. package/src/web/search/providers/base.ts +13 -0
  113. package/src/web/search/providers/brave.ts +2 -5
  114. package/src/web/search/providers/codex.ts +20 -24
  115. package/src/web/search/providers/gemini.ts +39 -1
  116. package/src/web/search/providers/jina.ts +2 -5
  117. package/src/web/search/providers/kagi.ts +3 -8
  118. package/src/web/search/providers/kimi.ts +3 -7
  119. package/src/web/search/providers/parallel.ts +3 -8
  120. package/src/web/search/providers/synthetic.ts +3 -7
  121. package/src/web/search/providers/tavily.ts +15 -11
  122. package/src/web/search/providers/utils.ts +36 -0
  123. package/src/web/search/providers/zai.ts +3 -7
@@ -42,14 +42,13 @@ export interface TodoWriteToolDetails {
42
42
  // Schema
43
43
  // =============================================================================
44
44
 
45
- const StatusEnum = StringEnum(["pending", "in_progress", "completed", "abandoned"] as const, {
46
- description: "Task status",
47
- });
48
-
49
45
  const InputTask = Type.Object({
50
46
  content: Type.String({ description: "Task description" }),
51
- status: Type.Optional(StatusEnum),
52
- notes: Type.Optional(Type.String({ description: "Additional context or notes" })),
47
+ status: Type.Optional(
48
+ StringEnum(["pending", "in_progress", "completed", "abandoned"] as const, {
49
+ description: "Task status",
50
+ }),
51
+ ),
53
52
  details: Type.Optional(
54
53
  Type.String({ description: "Implementation details, file paths, and specifics (shown only when active)" }),
55
54
  ),
@@ -60,39 +59,26 @@ const InputPhase = Type.Object({
60
59
  tasks: Type.Optional(Type.Array(InputTask)),
61
60
  });
62
61
 
62
+ const AddNoteEntry = Type.Object({
63
+ id: Type.String({ description: "Task ID, e.g. task-3" }),
64
+ notes: Type.String({ description: "Notes to append" }),
65
+ });
66
+
67
+ const AddTaskEntry = Type.Object({
68
+ phase: Type.String({ description: "Phase name or ID" }),
69
+ content: Type.String({ description: "Task description" }),
70
+ details: Type.Optional(Type.String({ description: "Implementation details, file paths, and specifics" })),
71
+ });
72
+
63
73
  const todoWriteSchema = Type.Object({
64
- ops: Type.Array(
65
- Type.Union([
66
- Type.Object({
67
- op: Type.Literal("replace"),
68
- phases: Type.Array(InputPhase),
69
- }),
70
- Type.Object({
71
- op: Type.Literal("add_phase"),
72
- name: Type.String({ description: "Phase name" }),
73
- tasks: Type.Optional(Type.Array(InputTask)),
74
- }),
75
- Type.Object({
76
- op: Type.Literal("add_task"),
77
- phase: Type.String({ description: "Phase ID, e.g. phase-1" }),
78
- content: Type.String({ description: "Task description" }),
79
- notes: Type.Optional(Type.String({ description: "Additional context or notes" })),
80
- details: Type.Optional(Type.String({ description: "Implementation details, file paths, and specifics" })),
81
- }),
82
- Type.Object({
83
- op: Type.Literal("update"),
84
- id: Type.String({ description: "Task ID, e.g. task-3" }),
85
- status: Type.Optional(StatusEnum),
86
- content: Type.Optional(Type.String({ description: "Updated task description" })),
87
- notes: Type.Optional(Type.String({ description: "Additional context or notes" })),
88
- details: Type.Optional(Type.String({ description: "Updated details" })),
89
- }),
90
- Type.Object({
91
- op: Type.Literal("remove_task"),
92
- id: Type.String({ description: "Task ID, e.g. task-3" }),
93
- }),
94
- ]),
95
- ),
74
+ phases: Type.Optional(Type.Array(InputPhase, { description: "Replace entire todo list with these phases" })),
75
+ start: Type.Optional(Type.String({ description: "Task ID to start, e.g. task-3" })),
76
+ complete: Type.Optional(Type.Array(Type.String(), { description: "Task IDs to mark completed" })),
77
+ abandon: Type.Optional(Type.Array(Type.String(), { description: "Task IDs to mark abandoned" })),
78
+ remove: Type.Optional(Type.Array(Type.String(), { description: "Task IDs to remove" })),
79
+ add_notes: Type.Optional(Type.Array(AddNoteEntry, { description: "Notes to append to tasks" })),
80
+ add_tasks: Type.Optional(Type.Array(AddTaskEntry, { description: "Tasks to add" })),
81
+ add_phase: Type.Optional(InputPhase),
96
82
  });
97
83
 
98
84
  type TodoWriteParams = Static<typeof todoWriteSchema>;
@@ -124,7 +110,7 @@ function findTask(phases: TodoPhase[], id: string): TodoItem | undefined {
124
110
  }
125
111
 
126
112
  function buildPhaseFromInput(
127
- input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; notes?: string; details?: string }> },
113
+ input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; details?: string }> },
128
114
  phaseId: string,
129
115
  nextTaskId: number,
130
116
  ): { phase: TodoPhase; nextTaskId: number } {
@@ -135,7 +121,6 @@ function buildPhaseFromInput(
135
121
  id: `task-${tid++}`,
136
122
  content: t.content,
137
123
  status: t.status ?? "pending",
138
- notes: t.notes,
139
124
  details: t.details,
140
125
  });
141
126
  }
@@ -207,79 +192,108 @@ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPha
207
192
  return [];
208
193
  }
209
194
 
210
- function applyOps(file: TodoFile, ops: TodoWriteParams["ops"]): { file: TodoFile; errors: string[] } {
195
+ function resolveTaskOrError(phases: TodoPhase[], id: string, errors: string[]): TodoItem | undefined {
196
+ const task = findTask(phases, id);
197
+ if (!task) {
198
+ const totalTasks = phases.reduce((sum, p) => sum + p.tasks.length, 0);
199
+ const hint = totalTasks === 0 ? " (todo list is empty — was it replaced or not yet created?)" : "";
200
+ errors.push(`Task "${id}" not found${hint}`);
201
+ }
202
+ return task;
203
+ }
204
+
205
+ function applyParams(file: TodoFile, params: TodoWriteParams): { file: TodoFile; errors: string[] } {
211
206
  const errors: string[] = [];
212
207
 
213
- for (const op of ops) {
214
- switch (op.op) {
215
- case "replace": {
216
- const next = makeEmptyFile();
217
- for (const inputPhase of op.phases) {
218
- const phaseId = `phase-${next.nextPhaseId++}`;
219
- const { phase, nextTaskId } = buildPhaseFromInput(inputPhase, phaseId, next.nextTaskId);
220
- next.phases.push(phase);
221
- next.nextTaskId = nextTaskId;
222
- }
223
- file = next;
224
- break;
225
- }
208
+ // Replace (must be first — replaces entire state)
209
+ if (params.phases) {
210
+ const next = makeEmptyFile();
211
+ for (const inputPhase of params.phases) {
212
+ const phaseId = `phase-${next.nextPhaseId++}`;
213
+ const { phase, nextTaskId } = buildPhaseFromInput(inputPhase, phaseId, next.nextTaskId);
214
+ next.phases.push(phase);
215
+ next.nextTaskId = nextTaskId;
216
+ }
217
+ file = next;
218
+ }
219
+
220
+ if (params.add_phase) {
221
+ const phaseId = `phase-${file.nextPhaseId++}`;
222
+ const { phase, nextTaskId } = buildPhaseFromInput(params.add_phase, phaseId, file.nextTaskId);
223
+ file.phases.push(phase);
224
+ file.nextTaskId = nextTaskId;
225
+ }
226
226
 
227
- case "add_phase": {
228
- const phaseId = `phase-${file.nextPhaseId++}`;
229
- const { phase, nextTaskId } = buildPhaseFromInput(op, phaseId, file.nextTaskId);
230
- file.phases.push(phase);
231
- file.nextTaskId = nextTaskId;
232
- break;
227
+ if (params.add_tasks) {
228
+ for (const entry of params.add_tasks) {
229
+ const target = file.phases.find(p => p.id === entry.phase || p.name === entry.phase);
230
+ if (!target) {
231
+ errors.push(`Phase "${entry.phase}" not found`);
232
+ continue;
233
233
  }
234
+ target.tasks.push({
235
+ id: `task-${file.nextTaskId++}`,
236
+ content: entry.content,
237
+ status: "pending",
238
+ details: entry.details,
239
+ });
240
+ }
241
+ }
242
+
243
+ if (params.complete) {
244
+ for (const id of params.complete) {
245
+ const task = resolveTaskOrError(file.phases, id, errors);
246
+ if (task) task.status = "completed";
247
+ }
248
+ }
234
249
 
235
- case "add_task": {
236
- const target = file.phases.find(p => p.id === op.phase);
237
- if (!target) {
238
- errors.push(`Phase "${op.phase}" not found`);
250
+ if (params.abandon) {
251
+ for (const id of params.abandon) {
252
+ const task = resolveTaskOrError(file.phases, id, errors);
253
+ if (task) task.status = "abandoned";
254
+ }
255
+ }
256
+
257
+ if (params.remove) {
258
+ for (const id of params.remove) {
259
+ let removed = false;
260
+ for (const phase of file.phases) {
261
+ const idx = phase.tasks.findIndex(t => t.id === id);
262
+ if (idx !== -1) {
263
+ phase.tasks.splice(idx, 1);
264
+ removed = true;
239
265
  break;
240
266
  }
241
- target.tasks.push({
242
- id: `task-${file.nextTaskId++}`,
243
- content: op.content,
244
- status: "pending",
245
- notes: op.notes,
246
- details: op.details,
247
- });
248
- break;
249
267
  }
268
+ if (!removed) {
269
+ const totalTasks = file.phases.reduce((sum, p) => sum + p.tasks.length, 0);
270
+ const hint = totalTasks === 0 ? " (todo list is empty)" : "";
271
+ errors.push(`Task "${id}" not found${hint}`);
272
+ }
273
+ }
274
+ }
250
275
 
251
- case "update": {
252
- const task = findTask(file.phases, op.id);
253
- if (!task) {
254
- const totalTasks = file.phases.reduce((sum, p) => sum + p.tasks.length, 0);
255
- const hint = totalTasks === 0 ? " (todo list is empty — was it replaced or not yet created?)" : "";
256
- errors.push(`Task "${op.id}" not found${hint}`);
257
- break;
258
- }
259
- if (op.status !== undefined) task.status = op.status;
260
- if (op.content !== undefined) task.content = op.content;
261
- if (op.notes !== undefined) task.notes = op.notes;
262
- if (op.details !== undefined) task.details = op.details;
263
- break;
276
+ if (params.add_notes) {
277
+ for (const entry of params.add_notes) {
278
+ const task = resolveTaskOrError(file.phases, entry.id, errors);
279
+ if (task) {
280
+ task.notes = task.notes ? `${task.notes}\n${entry.notes}` : entry.notes;
264
281
  }
282
+ }
283
+ }
265
284
 
266
- case "remove_task": {
267
- let removed = false;
268
- for (const phase of file.phases) {
269
- const idx = phase.tasks.findIndex(t => t.id === op.id);
270
- if (idx !== -1) {
271
- phase.tasks.splice(idx, 1);
272
- removed = true;
273
- break;
285
+ if (params.start) {
286
+ const task = resolveTaskOrError(file.phases, params.start, errors);
287
+ if (task) {
288
+ // Demote any currently in_progress tasks before promoting the target
289
+ for (const phase of file.phases) {
290
+ for (const t of phase.tasks) {
291
+ if (t.status === "in_progress" && t.id !== task.id) {
292
+ t.status = "pending";
274
293
  }
275
294
  }
276
- if (!removed) {
277
- const totalTasks = file.phases.reduce((sum, p) => sum + p.tasks.length, 0);
278
- const hint = totalTasks === 0 ? " (todo list is empty)" : "";
279
- errors.push(`Task "${op.id}" not found${hint}`);
280
- }
281
- break;
282
295
  }
296
+ task.status = "in_progress";
283
297
  }
284
298
  }
285
299
 
@@ -318,6 +332,11 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
318
332
  lines.push(` ${line}`);
319
333
  }
320
334
  }
335
+ if (task.notes) {
336
+ for (const line of task.notes.split("\n")) {
337
+ lines.push(` Note: ${line}`);
338
+ }
339
+ }
321
340
  }
322
341
  }
323
342
  lines.push(
@@ -365,7 +384,7 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
365
384
  ): Promise<AgentToolResult<TodoWriteToolDetails>> {
366
385
  const previousPhases = this.session.getTodoPhases?.() ?? [];
367
386
  const current = fileFromPhases(previousPhases);
368
- const { file: updated, errors } = applyOps(current, params.ops);
387
+ const { file: updated, errors } = applyParams(current, params);
369
388
  this.session.setTodoPhases?.(updated.phases);
370
389
  const storage = this.session.getSessionFile() ? "session" : "memory";
371
390
 
@@ -381,7 +400,14 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
381
400
  // =============================================================================
382
401
 
383
402
  interface TodoWriteRenderArgs {
384
- ops?: Array<{ op: string }>;
403
+ phases?: unknown;
404
+ start?: string;
405
+ complete?: string[];
406
+ abandon?: string[];
407
+ remove?: string[];
408
+ add_notes?: unknown[];
409
+ add_tasks?: unknown[];
410
+ add_phase?: unknown;
385
411
  }
386
412
 
387
413
  function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
@@ -404,8 +430,16 @@ function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string
404
430
 
405
431
  export const todoWriteToolRenderer = {
406
432
  renderCall(args: TodoWriteRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
407
- const count = args.ops?.length ?? 0;
408
- const label = count === 1 ? (args.ops?.[0]?.op ?? "update") : `${count} ops`;
433
+ const ops: string[] = [];
434
+ if (args.phases) ops.push("replace");
435
+ if (args.complete?.length) ops.push(`complete ${args.complete.length}`);
436
+ if (args.start) ops.push("start");
437
+ if (args.abandon?.length) ops.push("abandon");
438
+ if (args.remove?.length) ops.push("remove");
439
+ if (args.add_notes?.length) ops.push("add_notes");
440
+ if (args.add_tasks?.length) ops.push("add_tasks");
441
+ if (args.add_phase) ops.push("add_phase");
442
+ const label = ops.length > 0 ? ops.join(", ") : "update";
409
443
  const text = renderStatusLine({ icon: "pending", title: "Todo Write", meta: [label] }, uiTheme);
410
444
  return new Text(text, 0, 0);
411
445
  },
@@ -1,13 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import type {
5
- AgentTool,
6
- AgentToolContext,
7
- AgentToolResult,
8
- AgentToolUpdateCallback,
9
- ToolCallContext,
10
- } from "@oh-my-pi/pi-agent-core";
4
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
11
5
  import type { Component } from "@oh-my-pi/pi-tui";
12
6
  import { Text } from "@oh-my-pi/pi-tui";
13
7
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
@@ -32,6 +26,7 @@ import {
32
26
  formatMoreItems,
33
27
  formatStatusIcon,
34
28
  formatTitle,
29
+ getLspBatchRequest,
35
30
  replaceTabs,
36
31
  shortenPath,
37
32
  } from "./render-utils";
@@ -61,22 +56,6 @@ export interface WriteToolDetails {
61
56
  meta?: OutputMeta;
62
57
  }
63
58
 
64
- const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
65
-
66
- function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined {
67
- if (!toolCall) {
68
- return undefined;
69
- }
70
- const hasOtherWrites = toolCall.toolCalls.some(
71
- (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
72
- );
73
- if (!hasOtherWrites) {
74
- return undefined;
75
- }
76
- const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some(call => LSP_BATCH_TOOLS.has(call.name));
77
- return { id: toolCall.batchId, flush: !hasLaterWrites };
78
- }
79
-
80
59
  /**
81
60
  * Strip hashline display prefixes from write content.
82
61
  *
@@ -18,7 +18,7 @@ export interface CodeCellOptions {
18
18
  index?: number;
19
19
  total?: number;
20
20
  title?: string;
21
- status?: "pending" | "running" | "complete" | "error";
21
+ status?: "pending" | "running" | "warning" | "complete" | "error";
22
22
  spinnerFrame?: number;
23
23
  duration?: number;
24
24
  output?: string;
@@ -32,6 +32,7 @@ function getState(status?: CodeCellOptions["status"]): State | undefined {
32
32
  if (!status) return undefined;
33
33
  if (status === "complete") return "success";
34
34
  if (status === "error") return "error";
35
+ if (status === "warning") return "warning";
35
36
  if (status === "running") return "running";
36
37
  return "pending";
37
38
  }
@@ -45,9 +46,11 @@ function formatHeader(options: CodeCellOptions, theme: Theme): { title: string;
45
46
  ? "success"
46
47
  : status === "error"
47
48
  ? "error"
48
- : status === "running"
49
- ? "running"
50
- : "pending",
49
+ : status === "warning"
50
+ ? "warning"
51
+ : status === "running"
52
+ ? "running"
53
+ : "pending",
51
54
  theme,
52
55
  spinnerFrame,
53
56
  );
@@ -78,10 +81,12 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
78
81
  const { title, meta } = formatHeader(options, theme);
79
82
  const state = getState(options.status);
80
83
 
81
- const rawCodeLines = highlightCode(replaceTabs(code ?? ""), language);
84
+ const normalizedCode = replaceTabs(code ?? "");
85
+ const rawCodeLines = normalizedCode.split("\n");
82
86
  const maxCodeLines = expanded ? rawCodeLines.length : Math.min(rawCodeLines.length, codeMaxLines);
83
- const codeLines = rawCodeLines.slice(0, maxCodeLines);
84
- const hiddenCodeLines = rawCodeLines.length - codeLines.length;
87
+ const visibleCode = rawCodeLines.slice(0, maxCodeLines).join("\n");
88
+ const codeLines = highlightCode(visibleCode, language);
89
+ const hiddenCodeLines = rawCodeLines.length - maxCodeLines;
85
90
  if (hiddenCodeLines > 0) {
86
91
  const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0);
87
92
  const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`;
@@ -1,10 +1,11 @@
1
1
  import { $env, $flag } from "@oh-my-pi/pi-utils";
2
2
 
3
- export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim";
3
+ export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim" | "apply_patch";
4
4
 
5
5
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
6
6
 
7
7
  const EDIT_MODE_IDS = {
8
+ apply_patch: "apply_patch",
8
9
  chunk: "chunk",
9
10
  hashline: "hashline",
10
11
  patch: "patch",
@@ -38,7 +39,7 @@ export function resolveEditMode(session: EditModeSessionLike): EditMode {
38
39
  if (envMode) return envMode;
39
40
 
40
41
  if (!$flag("PI_STRICT_EDIT_MODE")) {
41
- if (activeModel?.includes("spark")) return "replace";
42
+ if (activeModel?.includes("spark")) return "apply_patch";
42
43
  if (activeModel?.includes("nano")) return "replace";
43
44
  if (activeModel?.includes("mini")) return "replace";
44
45
  if (activeModel?.includes("haiku")) return "replace";
package/src/utils/git.ts CHANGED
@@ -190,7 +190,7 @@ async function runCommand(
190
190
  const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
191
191
  const child = Bun.spawn(["git", ...commandArgs], {
192
192
  cwd,
193
- env: options.env ? { ...process.env, ...options.env } : undefined,
193
+ env: options.env ? { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...options.env } : undefined,
194
194
  signal: options.signal,
195
195
  stdin: normalizeStdin(options.stdin),
196
196
  stdout: "pipe",
package/src/vim/engine.ts CHANGED
@@ -294,6 +294,23 @@ function findParagraphEnd(lines: string[], line: number): number {
294
294
  return index;
295
295
  }
296
296
 
297
+ function cloneUndoStack(stack: VimUndoEntry[]): VimUndoEntry[] {
298
+ return stack.map(entry => ({
299
+ before: {
300
+ ...entry.before,
301
+ lines: [...entry.before.lines],
302
+ cursor: clonePosition(entry.before.cursor),
303
+ baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null,
304
+ },
305
+ after: {
306
+ ...entry.after,
307
+ lines: [...entry.after.lines],
308
+ cursor: clonePosition(entry.after.cursor),
309
+ baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null,
310
+ },
311
+ }));
312
+ }
313
+
297
314
  export class VimEngine {
298
315
  buffer: VimBuffer;
299
316
  inputMode: VimInputMode = "normal";
@@ -361,34 +378,8 @@ export class VimEngine {
361
378
  inserted: this.#pendingChange.inserted,
362
379
  }
363
380
  : null;
364
- next.#undoStack = this.#undoStack.map(entry => ({
365
- before: {
366
- ...entry.before,
367
- lines: [...entry.before.lines],
368
- cursor: clonePosition(entry.before.cursor),
369
- baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null,
370
- },
371
- after: {
372
- ...entry.after,
373
- lines: [...entry.after.lines],
374
- cursor: clonePosition(entry.after.cursor),
375
- baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null,
376
- },
377
- }));
378
- next.#redoStack = this.#redoStack.map(entry => ({
379
- before: {
380
- ...entry.before,
381
- lines: [...entry.before.lines],
382
- cursor: clonePosition(entry.before.cursor),
383
- baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null,
384
- },
385
- after: {
386
- ...entry.after,
387
- lines: [...entry.after.lines],
388
- cursor: clonePosition(entry.after.cursor),
389
- baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null,
390
- },
391
- }));
381
+ next.#undoStack = cloneUndoStack(this.#undoStack);
382
+ next.#redoStack = cloneUndoStack(this.#redoStack);
392
383
  return next;
393
384
  }
394
385
 
@@ -913,22 +904,14 @@ export class VimEngine {
913
904
  }
914
905
  case "d": {
915
906
  await this.#applyAtomicChange(tokens, () => {
916
- this.register = {
917
- kind: visual.linewise ? "line" : "char",
918
- text: this.buffer.getText().slice(visual.start, visual.end),
919
- };
920
- this.buffer.deleteOffsets(visual.start, visual.end);
907
+ this.#yankAndDeleteRange(visual);
921
908
  });
922
909
  this.#clearSelection();
923
910
  return;
924
911
  }
925
912
  case "c": {
926
913
  await this.#startInsertChange(tokens, () => {
927
- this.register = {
928
- kind: visual.linewise ? "line" : "char",
929
- text: this.buffer.getText().slice(visual.start, visual.end),
930
- };
931
- this.buffer.deleteOffsets(visual.start, visual.end);
914
+ this.#yankAndDeleteRange(visual);
932
915
  });
933
916
  this.#clearSelection();
934
917
  return;
@@ -1106,11 +1089,7 @@ export class VimEngine {
1106
1089
  return nextIndex + 1;
1107
1090
  case "s":
1108
1091
  await this.#startInsertChange(["s"], () => {
1109
- const start = this.buffer.currentOffset();
1110
- this.register = {
1111
- kind: "char",
1112
- text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)),
1113
- };
1092
+ this.#deleteCharsForward(count);
1114
1093
  });
1115
1094
  return nextIndex + 1;
1116
1095
  case "S":
@@ -1118,11 +1097,7 @@ export class VimEngine {
1118
1097
  return nextIndex + 1;
1119
1098
  case "x":
1120
1099
  await this.#applyAtomicChange(["x"], () => {
1121
- const start = this.buffer.currentOffset();
1122
- this.register = {
1123
- kind: "char",
1124
- text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)),
1125
- };
1100
+ this.#deleteCharsForward(count);
1126
1101
  });
1127
1102
  return nextIndex + 1;
1128
1103
  case "X":
@@ -1559,11 +1534,7 @@ export class VimEngine {
1559
1534
  if (operator === "d") {
1560
1535
  const range = this.#resolveMotionRange(motion);
1561
1536
  await this.#applyAtomicChange(tokens, () => {
1562
- this.register = {
1563
- kind: range.linewise ? "line" : "char",
1564
- text: this.buffer.getText().slice(range.start, range.end),
1565
- };
1566
- this.buffer.deleteOffsets(range.start, range.end);
1537
+ this.#yankAndDeleteRange(range);
1567
1538
  });
1568
1539
  return;
1569
1540
  }
@@ -1571,16 +1542,28 @@ export class VimEngine {
1571
1542
  if (operator === "c") {
1572
1543
  const range = this.#resolveMotionRange(motion);
1573
1544
  await this.#startInsertChange(tokens, () => {
1574
- this.register = {
1575
- kind: range.linewise ? "line" : "char",
1576
- text: this.buffer.getText().slice(range.start, range.end),
1577
- };
1578
- this.buffer.deleteOffsets(range.start, range.end);
1545
+ this.#yankAndDeleteRange(range);
1579
1546
  });
1580
1547
  return;
1581
1548
  }
1582
1549
  }
1583
1550
 
1551
+ #yankAndDeleteRange(range: { start: number; end: number; linewise: boolean }): void {
1552
+ this.register = {
1553
+ kind: range.linewise ? "line" : "char",
1554
+ text: this.buffer.getText().slice(range.start, range.end),
1555
+ };
1556
+ this.buffer.deleteOffsets(range.start, range.end);
1557
+ }
1558
+
1559
+ #deleteCharsForward(count: number): void {
1560
+ const start = this.buffer.currentOffset();
1561
+ this.register = {
1562
+ kind: "char",
1563
+ text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)),
1564
+ };
1565
+ }
1566
+
1584
1567
  async #changeWholeLines(count: number, tokens: readonly string[]): Promise<void> {
1585
1568
  await this.#startInsertChange(tokens, () => {
1586
1569
  const start = this.buffer.cursor.line;
@@ -1,19 +1,6 @@
1
1
  import { tryParseJson } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
- import { buildResult, formatNumber, loadPage } from "./types";
4
-
5
- /**
6
- * Check if content looks like HTML
7
- */
8
- function looksLikeHtml(content: string): boolean {
9
- const trimmed = content.trim().toLowerCase();
10
- return (
11
- trimmed.startsWith("<!doctype") ||
12
- trimmed.startsWith("<html") ||
13
- trimmed.startsWith("<head") ||
14
- trimmed.startsWith("<body")
15
- );
16
- }
3
+ import { buildResult, formatNumber, loadPage, looksLikeHtml } from "./types";
17
4
 
18
5
  /**
19
6
  * Handle crates.io URLs via API
@@ -283,3 +283,16 @@ export function getLocalizedText(value: LocalizedText, defaultLocale?: string):
283
283
  value["en-US"] ?? value.en_US ?? value.en ?? Object.values(value).find(v => typeof v === "string") ?? undefined
284
284
  );
285
285
  }
286
+
287
+ /**
288
+ * Check if content looks like HTML by inspecting the leading tag.
289
+ */
290
+ export function looksLikeHtml(content: string): boolean {
291
+ const trimmed = content.trim().toLowerCase();
292
+ return (
293
+ trimmed.startsWith("<!doctype") ||
294
+ trimmed.startsWith("<html") ||
295
+ trimmed.startsWith("<head") ||
296
+ trimmed.startsWith("<body")
297
+ );
298
+ }
@@ -4,6 +4,19 @@ import type { SearchProviderId, SearchResponse } from "../types";
4
4
  export interface SearchParams {
5
5
  query: string;
6
6
  limit?: number;
7
+ /**
8
+ * Temporal filter narrowing results to the specified time window.
9
+ *
10
+ * Providers MUST interpret this as a pure time filter. Providers MUST NOT
11
+ * use recency as an implicit signal to change topic scope, content domain,
12
+ * or ranking strategy. If a provider API couples temporal filtering with
13
+ * other dimensions (e.g. Tavily's `topic=news`), the provider implementation
14
+ * is responsible for decoupling them before calling the upstream API.
15
+ *
16
+ * Providers that do not support temporal filtering MUST ignore this field
17
+ * silently; they MUST NOT approximate it by rewriting the query or altering
18
+ * any other request parameter.
19
+ */
7
20
  recency?: "day" | "week" | "month" | "year";
8
21
  systemPrompt: string;
9
22
  signal?: AbortSignal;