@oh-my-pi/pi-coding-agent 12.19.3 → 13.0.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 (103) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +7 -7
  3. package/src/commit/prompts/analysis-system.md +3 -3
  4. package/src/commit/prompts/analysis-user.md +14 -14
  5. package/src/commit/prompts/changelog-system.md +4 -4
  6. package/src/commit/prompts/changelog-user.md +4 -4
  7. package/src/commit/prompts/file-observer-system.md +2 -2
  8. package/src/commit/prompts/file-observer-user.md +2 -2
  9. package/src/commit/prompts/reduce-system.md +4 -4
  10. package/src/commit/prompts/reduce-user.md +6 -6
  11. package/src/commit/prompts/summary-system.md +4 -4
  12. package/src/commit/prompts/summary-user.md +6 -6
  13. package/src/discovery/helpers.ts +13 -1
  14. package/src/internal-urls/docs-index.generated.ts +2 -2
  15. package/src/internal-urls/index.ts +8 -3
  16. package/src/internal-urls/local-protocol.ts +223 -0
  17. package/src/internal-urls/{docs-protocol.ts → pi-protocol.ts} +12 -12
  18. package/src/internal-urls/router.ts +1 -1
  19. package/src/internal-urls/types.ts +1 -1
  20. package/src/ipy/executor.ts +4 -32
  21. package/src/memories/index.ts +1 -1
  22. package/src/modes/controllers/event-controller.ts +4 -4
  23. package/src/modes/interactive-mode.ts +84 -64
  24. package/src/modes/types.ts +11 -3
  25. package/src/modes/utils/ui-helpers.ts +5 -3
  26. package/src/patch/hashline.ts +42 -42
  27. package/src/patch/index.ts +24 -21
  28. package/src/patch/shared.ts +21 -43
  29. package/src/plan-mode/approved-plan.ts +55 -0
  30. package/src/prompts/agents/designer.md +6 -6
  31. package/src/prompts/agents/explore.md +4 -4
  32. package/src/prompts/agents/frontmatter.md +1 -0
  33. package/src/prompts/agents/init.md +10 -10
  34. package/src/prompts/agents/plan.md +6 -6
  35. package/src/prompts/agents/reviewer.md +4 -3
  36. package/src/prompts/agents/task.md +10 -10
  37. package/src/prompts/compaction/branch-summary.md +3 -3
  38. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  39. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  40. package/src/prompts/compaction/compaction-summary.md +5 -5
  41. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  42. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  43. package/src/prompts/memories/consolidation.md +5 -5
  44. package/src/prompts/memories/read-path.md +11 -0
  45. package/src/prompts/memories/stage_one_input.md +1 -1
  46. package/src/prompts/memories/stage_one_system.md +5 -5
  47. package/src/prompts/review-request.md +4 -4
  48. package/src/prompts/system/agent-creation-architect.md +17 -17
  49. package/src/prompts/system/agent-creation-user.md +2 -2
  50. package/src/prompts/system/custom-system-prompt.md +6 -6
  51. package/src/prompts/system/plan-mode-active.md +20 -20
  52. package/src/prompts/system/plan-mode-approved.md +9 -7
  53. package/src/prompts/system/plan-mode-reference.md +2 -2
  54. package/src/prompts/system/plan-mode-subagent.md +8 -8
  55. package/src/prompts/system/subagent-submit-reminder.md +5 -5
  56. package/src/prompts/system/subagent-system-prompt.md +9 -9
  57. package/src/prompts/system/subagent-user-prompt.md +3 -5
  58. package/src/prompts/system/summarization-system.md +1 -1
  59. package/src/prompts/system/system-prompt.md +109 -84
  60. package/src/prompts/system/title-system.md +2 -2
  61. package/src/prompts/system/ttsr-interrupt.md +2 -2
  62. package/src/prompts/system/web-search.md +16 -16
  63. package/src/prompts/tools/ask.md +6 -6
  64. package/src/prompts/tools/bash.md +9 -9
  65. package/src/prompts/tools/browser.md +5 -5
  66. package/src/prompts/tools/cancel-job.md +2 -2
  67. package/src/prompts/tools/exit-plan-mode.md +13 -10
  68. package/src/prompts/tools/find.md +2 -2
  69. package/src/prompts/tools/gemini-image.md +7 -7
  70. package/src/prompts/tools/grep.md +4 -3
  71. package/src/prompts/tools/hashline.md +37 -39
  72. package/src/prompts/tools/patch.md +5 -5
  73. package/src/prompts/tools/poll-jobs.md +1 -1
  74. package/src/prompts/tools/python.md +8 -10
  75. package/src/prompts/tools/read.md +2 -12
  76. package/src/prompts/tools/replace.md +6 -6
  77. package/src/prompts/tools/ssh.md +2 -7
  78. package/src/prompts/tools/task.md +34 -23
  79. package/src/prompts/tools/todo-write.md +65 -49
  80. package/src/prompts/tools/web-search.md +2 -2
  81. package/src/prompts/tools/write.md +4 -3
  82. package/src/sdk.ts +11 -9
  83. package/src/session/agent-session.ts +92 -51
  84. package/src/session/artifacts.ts +1 -1
  85. package/src/session/messages.ts +1 -0
  86. package/src/task/agents.ts +1 -0
  87. package/src/task/index.ts +2 -1
  88. package/src/task/render.ts +2 -2
  89. package/src/task/types.ts +1 -0
  90. package/src/tools/bash-interactive.ts +1 -1
  91. package/src/tools/bash-skill-urls.ts +3 -2
  92. package/src/tools/bash.ts +21 -12
  93. package/src/tools/exit-plan-mode.ts +30 -2
  94. package/src/tools/grep.ts +131 -75
  95. package/src/tools/index.ts +13 -3
  96. package/src/tools/path-utils.ts +2 -1
  97. package/src/tools/plan-mode-guard.ts +8 -8
  98. package/src/tools/python.ts +0 -2
  99. package/src/tools/read.ts +2 -2
  100. package/src/tools/todo-write.ts +276 -146
  101. package/src/internal-urls/plan-protocol.ts +0 -95
  102. package/src/modes/components/todo-display.ts +0 -114
  103. package/src/prompts/memories/read_path.md +0 -11
@@ -1,9 +1,7 @@
1
- import path from "node:path";
2
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
2
  import { StringEnum } from "@oh-my-pi/pi-ai";
4
3
  import type { Component } from "@oh-my-pi/pi-tui";
5
4
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { logger, Snowflake } from "@oh-my-pi/pi-utils";
7
5
  import { type Static, Type } from "@sinclair/typebox";
8
6
  import chalk from "chalk";
9
7
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -11,138 +9,274 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
9
  import type { Theme } from "../modes/theme/theme";
12
10
  import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" };
13
11
  import type { ToolSession } from "../sdk";
12
+ import type { SessionEntry } from "../session/session-manager";
14
13
  import { renderStatusLine, renderTreeList } from "../tui";
15
14
  import { PREVIEW_LIMITS } from "./render-utils";
16
15
 
17
- const todoWriteSchema = Type.Object({
18
- todos: Type.Array(
19
- Type.Object({
20
- id: Type.Optional(Type.String({ description: "Stable todo id" })),
21
- content: Type.String({ description: "Task description (e.g., 'Run tests')" }),
22
- status: StringEnum(["pending", "in_progress", "completed"]),
23
- }),
24
- { description: "The updated todo list" },
25
- ),
26
- });
16
+ // =============================================================================
17
+ // Types
18
+ // =============================================================================
27
19
 
28
- type TodoStatus = "pending" | "in_progress" | "completed";
20
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
29
21
 
30
22
  export interface TodoItem {
31
23
  id: string;
32
24
  content: string;
33
25
  status: TodoStatus;
26
+ notes?: string;
34
27
  }
35
28
 
36
- interface TodoFile {
37
- updatedAt: number;
38
- todos: TodoItem[];
29
+ export interface TodoPhase {
30
+ id: string;
31
+ name: string;
32
+ tasks: TodoItem[];
39
33
  }
40
34
 
41
35
  export interface TodoWriteToolDetails {
42
- todos: TodoItem[];
43
- updatedAt: number;
36
+ phases: TodoPhase[];
44
37
  storage: "session" | "memory";
45
38
  }
46
39
 
47
- const TODO_FILE_NAME = "todos.json";
40
+ // =============================================================================
41
+ // Schema
42
+ // =============================================================================
43
+
44
+ const StatusEnum = StringEnum(["pending", "in_progress", "completed", "abandoned"] as const);
45
+
46
+ const InputTask = Type.Object({
47
+ content: Type.String(),
48
+ status: Type.Optional(StatusEnum),
49
+ notes: Type.Optional(Type.String()),
50
+ });
51
+
52
+ const InputPhase = Type.Object({
53
+ name: Type.String(),
54
+ tasks: Type.Optional(Type.Array(InputTask)),
55
+ });
56
+
57
+ const todoWriteSchema = Type.Object({
58
+ ops: Type.Array(
59
+ Type.Union([
60
+ Type.Object({
61
+ op: Type.Literal("replace"),
62
+ phases: Type.Array(InputPhase),
63
+ }),
64
+ Type.Object({
65
+ op: Type.Literal("add_phase"),
66
+ name: Type.String(),
67
+ tasks: Type.Optional(Type.Array(InputTask)),
68
+ }),
69
+ Type.Object({
70
+ op: Type.Literal("add_task"),
71
+ phase: Type.String({ description: "Phase ID, e.g. phase-1" }),
72
+ content: Type.String(),
73
+ notes: Type.Optional(Type.String()),
74
+ }),
75
+ Type.Object({
76
+ op: Type.Literal("update"),
77
+ id: Type.String({ description: "Task ID, e.g. task-3" }),
78
+ status: Type.Optional(StatusEnum),
79
+ content: Type.Optional(Type.String()),
80
+ notes: Type.Optional(Type.String()),
81
+ }),
82
+ Type.Object({
83
+ op: Type.Literal("remove_task"),
84
+ id: Type.String({ description: "Task ID, e.g. task-3" }),
85
+ }),
86
+ ]),
87
+ ),
88
+ });
48
89
 
49
90
  type TodoWriteParams = Static<typeof todoWriteSchema>;
50
91
 
51
- function normalizeTodoStatus(status?: string): TodoStatus {
52
- switch (status) {
53
- case "in_progress":
54
- return "in_progress";
55
- case "completed":
56
- case "done":
57
- case "complete":
58
- return "completed";
59
- default:
60
- return "pending";
92
+ // =============================================================================
93
+ // File format
94
+ // =============================================================================
95
+
96
+ interface TodoFile {
97
+ phases: TodoPhase[];
98
+ nextTaskId: number;
99
+ nextPhaseId: number;
100
+ }
101
+
102
+ // =============================================================================
103
+ // State helpers
104
+ // =============================================================================
105
+
106
+ function makeEmptyFile(): TodoFile {
107
+ return { phases: [], nextTaskId: 1, nextPhaseId: 1 };
108
+ }
109
+
110
+ function findTask(phases: TodoPhase[], id: string): TodoItem | undefined {
111
+ for (const phase of phases) {
112
+ const task = phase.tasks.find(t => t.id === id);
113
+ if (task) return task;
61
114
  }
115
+ return undefined;
62
116
  }
63
117
 
64
- function normalizeTodos(items: Array<{ id?: string; content?: string; status?: string }>): TodoItem[] {
65
- return items.map(item => {
66
- if (!item.content) {
67
- throw new Error("Todo content is required.");
68
- }
69
- const content = item.content.trim();
70
- if (!content) {
71
- throw new Error("Todo content cannot be empty.");
72
- }
73
- return {
74
- id: item.id && item.id.trim().length > 0 ? item.id : Snowflake.next(),
75
- content,
76
- status: normalizeTodoStatus(item.status),
77
- };
78
- });
118
+ function buildPhaseFromInput(
119
+ input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; notes?: string }> },
120
+ phaseId: string,
121
+ nextTaskId: number,
122
+ ): { phase: TodoPhase; nextTaskId: number } {
123
+ const tasks: TodoItem[] = [];
124
+ let tid = nextTaskId;
125
+ for (const t of input.tasks ?? []) {
126
+ tasks.push({ id: `task-${tid++}`, content: t.content, status: t.status ?? "pending", notes: t.notes });
127
+ }
128
+ return { phase: { id: phaseId, name: input.name, tasks }, nextTaskId: tid };
79
129
  }
80
130
 
81
- function validateSequentialTodos(todos: TodoItem[]): { valid: boolean; error?: string } {
82
- if (todos.length === 0) return { valid: true };
131
+ function getNextIds(phases: TodoPhase[]): { nextTaskId: number; nextPhaseId: number } {
132
+ let maxTaskId = 0;
133
+ let maxPhaseId = 0;
83
134
 
84
- const firstIncompleteIndex = todos.findIndex(todo => todo.status !== "completed");
85
- if (firstIncompleteIndex >= 0) {
86
- for (let i = firstIncompleteIndex + 1; i < todos.length; i++) {
87
- if (todos[i].status === "completed") {
88
- return {
89
- valid: false,
90
- error: `Error: Cannot complete "${todos[i].content}" before completing "${todos[firstIncompleteIndex].content}". Todos must be completed sequentially.`,
91
- };
92
- }
135
+ for (const phase of phases) {
136
+ const phaseMatch = /^phase-(\d+)$/.exec(phase.id);
137
+ if (phaseMatch) {
138
+ const value = Number.parseInt(phaseMatch[1], 10);
139
+ if (Number.isFinite(value) && value > maxPhaseId) maxPhaseId = value;
93
140
  }
94
- }
95
141
 
96
- const inProgressIndices = todos.reduce<number[]>((acc, todo, index) => {
97
- if (todo.status === "in_progress") acc.push(index);
98
- return acc;
99
- }, []);
100
-
101
- for (const idx of inProgressIndices) {
102
- const hasPriorIncomplete = todos.slice(0, idx).some(t => t.status === "pending");
103
- if (hasPriorIncomplete) {
104
- return {
105
- valid: false,
106
- error: `Cannot start "${todos[idx].content}" while earlier tasks are still pending.`,
107
- };
142
+ for (const task of phase.tasks) {
143
+ const taskMatch = /^task-(\d+)$/.exec(task.id);
144
+ if (!taskMatch) continue;
145
+ const value = Number.parseInt(taskMatch[1], 10);
146
+ if (Number.isFinite(value) && value > maxTaskId) maxTaskId = value;
108
147
  }
109
148
  }
110
149
 
111
- return { valid: true };
150
+ return { nextTaskId: maxTaskId + 1, nextPhaseId: maxPhaseId + 1 };
112
151
  }
113
152
 
114
- async function loadTodoFile(filePath: string): Promise<TodoFile | null> {
115
- const file = Bun.file(filePath);
116
- if (!(await file.exists())) return null;
117
- try {
118
- const text = await file.text();
119
- const data = JSON.parse(text) as TodoFile;
120
- if (!data || !Array.isArray(data.todos)) return null;
121
- return data;
122
- } catch (error) {
123
- logger.warn("Failed to read todo file", { path: filePath, error: String(error) });
124
- return null;
153
+ function fileFromPhases(phases: TodoPhase[]): TodoFile {
154
+ const { nextTaskId, nextPhaseId } = getNextIds(phases);
155
+ return { phases, nextTaskId, nextPhaseId };
156
+ }
157
+
158
+ function clonePhases(phases: TodoPhase[]): TodoPhase[] {
159
+ return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(task => ({ ...task })) }));
160
+ }
161
+
162
+ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[] {
163
+ for (let i = entries.length - 1; i >= 0; i--) {
164
+ const entry = entries[i];
165
+ if (entry.type !== "message") continue;
166
+
167
+ const message = entry.message as { role?: string; toolName?: string; details?: unknown; isError?: boolean };
168
+ if (message.role !== "toolResult" || message.toolName !== "todo_write" || message.isError) continue;
169
+
170
+ const details = message.details as { phases?: unknown } | undefined;
171
+ if (!details || !Array.isArray(details.phases)) continue;
172
+
173
+ return clonePhases(details.phases as TodoPhase[]);
125
174
  }
175
+
176
+ return [];
126
177
  }
127
178
 
128
- function formatTodoSummary(todos: TodoItem[]): string {
129
- if (todos.length === 0) return "Todo list cleared.";
130
- const completed = todos.filter(t => t.status === "completed").length;
131
- const inProgress = todos.filter(t => t.status === "in_progress").length;
132
- const pending = todos.filter(t => t.status === "pending").length;
133
- return `Saved ${todos.length} todos (${pending} pending, ${inProgress} in progress, ${completed} completed).`;
179
+ function applyOps(file: TodoFile, ops: TodoWriteParams["ops"]): { file: TodoFile; errors: string[] } {
180
+ const errors: string[] = [];
181
+
182
+ for (const op of ops) {
183
+ switch (op.op) {
184
+ case "replace": {
185
+ const next = makeEmptyFile();
186
+ for (const inputPhase of op.phases) {
187
+ const phaseId = `phase-${next.nextPhaseId++}`;
188
+ const { phase, nextTaskId } = buildPhaseFromInput(inputPhase, phaseId, next.nextTaskId);
189
+ next.phases.push(phase);
190
+ next.nextTaskId = nextTaskId;
191
+ }
192
+ file = next;
193
+ break;
194
+ }
195
+
196
+ case "add_phase": {
197
+ const phaseId = `phase-${file.nextPhaseId++}`;
198
+ const { phase, nextTaskId } = buildPhaseFromInput(op, phaseId, file.nextTaskId);
199
+ file.phases.push(phase);
200
+ file.nextTaskId = nextTaskId;
201
+ break;
202
+ }
203
+
204
+ case "add_task": {
205
+ const target = file.phases.find(p => p.id === op.phase);
206
+ if (!target) {
207
+ errors.push(`Phase "${op.phase}" not found`);
208
+ break;
209
+ }
210
+ target.tasks.push({
211
+ id: `task-${file.nextTaskId++}`,
212
+ content: op.content,
213
+ status: "pending",
214
+ notes: op.notes,
215
+ });
216
+ break;
217
+ }
218
+
219
+ case "update": {
220
+ const task = findTask(file.phases, op.id);
221
+ if (!task) {
222
+ errors.push(`Task "${op.id}" not found`);
223
+ break;
224
+ }
225
+ if (op.status !== undefined) task.status = op.status;
226
+ if (op.content !== undefined) task.content = op.content;
227
+ if (op.notes !== undefined) task.notes = op.notes;
228
+ break;
229
+ }
230
+
231
+ case "remove_task": {
232
+ let removed = false;
233
+ for (const phase of file.phases) {
234
+ const idx = phase.tasks.findIndex(t => t.id === op.id);
235
+ if (idx !== -1) {
236
+ phase.tasks.splice(idx, 1);
237
+ removed = true;
238
+ break;
239
+ }
240
+ }
241
+ if (!removed) errors.push(`Task "${op.id}" not found`);
242
+ break;
243
+ }
244
+ }
245
+ }
246
+
247
+ return { file, errors };
134
248
  }
135
249
 
136
- function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
137
- const checkbox = uiTheme.checkbox;
138
- switch (item.status) {
139
- case "completed":
140
- return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
141
- case "in_progress":
142
- return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
143
- default:
144
- return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
250
+ function formatSummary(phases: TodoPhase[], errors: string[]): string {
251
+ const tasks = phases.flatMap(p => p.tasks);
252
+ if (tasks.length === 0) return errors.length > 0 ? `Errors: ${errors.join("; ")}` : "Todo list cleared.";
253
+
254
+ // Find current phase
255
+ let currentIdx = phases.findIndex(p => p.tasks.some(t => t.status === "pending" || t.status === "in_progress"));
256
+ if (currentIdx === -1) currentIdx = phases.length - 1;
257
+ const current = phases[currentIdx];
258
+ const done = current.tasks.filter(t => t.status === "completed" || t.status === "abandoned").length;
259
+
260
+ const lines: string[] = [];
261
+ if (errors.length > 0) lines.push(`Errors: ${errors.join("; ")}`);
262
+ lines.push(
263
+ `Phase ${currentIdx + 1}/${phases.length} "${current.name}" — ${done}/${current.tasks.length} tasks complete`,
264
+ );
265
+ for (const phase of phases) {
266
+ lines.push(` ${phase.name}:`);
267
+ for (const task of phase.tasks) {
268
+ const sym =
269
+ task.status === "completed"
270
+ ? "✓"
271
+ : task.status === "in_progress"
272
+ ? "→"
273
+ : task.status === "abandoned"
274
+ ? "✗"
275
+ : "○";
276
+ lines.push(` ${sym} ${task.id} ${task.content}`);
277
+ }
145
278
  }
279
+ return lines.join("\n");
146
280
  }
147
281
 
148
282
  // =============================================================================
@@ -167,40 +301,15 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
167
301
  _onUpdate?: AgentToolUpdateCallback<TodoWriteToolDetails>,
168
302
  _context?: AgentToolContext,
169
303
  ): Promise<AgentToolResult<TodoWriteToolDetails>> {
170
- const todos = normalizeTodos(params.todos ?? []);
171
- const validation = validateSequentialTodos(todos);
172
- if (!validation.valid) {
173
- throw new Error(validation.error ?? "Todos must be completed sequentially.");
174
- }
175
- const updatedAt = Date.now();
176
-
177
- const sessionFile = this.session.getSessionFile();
178
- if (!sessionFile) {
179
- return {
180
- content: [{ type: "text", text: formatTodoSummary(todos) }],
181
- details: { todos, updatedAt, storage: "memory" },
182
- };
183
- }
184
-
185
- const todoPath = path.join(sessionFile.slice(0, -6), TODO_FILE_NAME);
186
- const existing = await loadTodoFile(todoPath);
187
- const storedTodos = existing?.todos ?? [];
188
- const merged = todos.length > 0 ? todos : [];
189
- const fileData: TodoFile = { updatedAt, todos: merged };
190
-
191
- try {
192
- await Bun.write(todoPath, JSON.stringify(fileData, null, 2));
193
- } catch (error) {
194
- logger.error("Failed to write todo file", { path: todoPath, error: String(error) });
195
- return {
196
- content: [{ type: "text", text: "Failed to save todos." }],
197
- details: { todos: storedTodos, updatedAt, storage: "session" },
198
- };
199
- }
304
+ const previousPhases = this.session.getTodoPhases?.() ?? [];
305
+ const current = fileFromPhases(previousPhases);
306
+ const { file: updated, errors } = applyOps(current, params.ops);
307
+ this.session.setTodoPhases?.(updated.phases);
308
+ const storage = this.session.getSessionFile() ? "session" : "memory";
200
309
 
201
310
  return {
202
- content: [{ type: "text", text: formatTodoSummary(merged) }],
203
- details: { todos: merged, updatedAt, storage: "session" },
311
+ content: [{ type: "text", text: formatSummary(updated.phases, errors) }],
312
+ details: { phases: updated.phases, storage },
204
313
  };
205
314
  }
206
315
  }
@@ -210,14 +319,28 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
210
319
  // =============================================================================
211
320
 
212
321
  interface TodoWriteRenderArgs {
213
- todos?: Array<{ id?: string; content?: string; status?: string }>;
322
+ ops?: Array<{ op: string }>;
323
+ }
324
+
325
+ function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
326
+ const checkbox = uiTheme.checkbox;
327
+ switch (item.status) {
328
+ case "completed":
329
+ return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
330
+ case "in_progress":
331
+ return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
332
+ case "abandoned":
333
+ return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`);
334
+ default:
335
+ return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
336
+ }
214
337
  }
215
338
 
216
339
  export const todoWriteToolRenderer = {
217
340
  renderCall(args: TodoWriteRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
218
- const count = args.todos?.length ?? 0;
219
- const meta = count > 0 ? [`${count} items`] : ["empty"];
220
- const text = renderStatusLine({ icon: "pending", title: "Todo Write", meta }, uiTheme);
341
+ const count = args.ops?.length ?? 0;
342
+ const label = count === 1 ? (args.ops?.[0]?.op ?? "update") : `${count} ops`;
343
+ const text = renderStatusLine({ icon: "pending", title: "Todo Write", meta: [label] }, uiTheme);
221
344
  return new Text(text, 0, 0);
222
345
  },
223
346
 
@@ -227,29 +350,36 @@ export const todoWriteToolRenderer = {
227
350
  uiTheme: Theme,
228
351
  _args?: TodoWriteRenderArgs,
229
352
  ): Component {
230
- const todos = result.details?.todos ?? [];
353
+ const phases = (result.details?.phases ?? []).filter(p => p.tasks.length > 0);
354
+ const allTasks = phases.flatMap(p => p.tasks);
231
355
  const header = renderStatusLine(
232
- { icon: "success", title: "Todo Write", meta: [`${todos.length} items`] },
356
+ { icon: "success", title: "Todo Write", meta: [`${allTasks.length} tasks`] },
233
357
  uiTheme,
234
358
  );
235
- if (todos.length === 0) {
359
+ if (allTasks.length === 0) {
236
360
  const fallback = result.content?.find(c => c.type === "text")?.text ?? "No todos";
237
361
  return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
238
362
  }
239
363
 
240
364
  const { expanded } = options;
241
- const treeLines = renderTreeList(
242
- {
243
- items: todos,
244
- expanded,
245
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
246
- itemType: "todo",
247
- renderItem: todo => formatTodoLine(todo, uiTheme, ""),
248
- },
249
- uiTheme,
250
- );
251
- const text = [header, ...treeLines].join("\n");
252
- return new Text(text, 0, 0);
365
+ const lines: string[] = [header];
366
+ for (const phase of phases) {
367
+ if (phases.length > 1) {
368
+ lines.push(uiTheme.fg("accent", ` ${uiTheme.tree.hook} ${phase.name}`));
369
+ }
370
+ const treeLines = renderTreeList(
371
+ {
372
+ items: phase.tasks,
373
+ expanded,
374
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
375
+ itemType: "todo",
376
+ renderItem: todo => formatTodoLine(todo, uiTheme, ""),
377
+ },
378
+ uiTheme,
379
+ );
380
+ lines.push(...treeLines);
381
+ }
382
+ return new Text(lines.join("\n"), 0, 0);
253
383
  },
254
384
  mergeCallAndResult: true,
255
385
  };
@@ -1,95 +0,0 @@
1
- /**
2
- * Protocol handler for plan:// URLs.
3
- *
4
- * Resolves plan references to plan files under the plans directory.
5
- *
6
- * URL forms:
7
- * - plan://<sessionId> (defaults to plan.md)
8
- * - plan://<sessionId>/plan.md
9
- * - plan://<plan-id>.md (resolves directly under plans dir)
10
- */
11
- import * as path from "node:path";
12
- import { isEnoent } from "@oh-my-pi/pi-utils";
13
- import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
14
-
15
- export interface PlanProtocolOptions {
16
- getPlansDirectory: (cwd?: string) => string;
17
- cwd: string;
18
- }
19
-
20
- function parsePlanUrl(input: string): InternalUrl {
21
- let parsed: URL;
22
- try {
23
- parsed = new URL(input);
24
- } catch {
25
- throw new Error(`Invalid URL: ${input}`);
26
- }
27
-
28
- const hostMatch = input.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i);
29
- let rawHost = hostMatch ? hostMatch[2] : parsed.hostname;
30
- try {
31
- rawHost = decodeURIComponent(rawHost);
32
- } catch {
33
- // Leave rawHost as-is if decoding fails.
34
- }
35
- (parsed as InternalUrl).rawHost = rawHost;
36
- return parsed as InternalUrl;
37
- }
38
-
39
- function normalizeRelativePath(host: string, pathname: string): string {
40
- const trimmedHost = host.replace(/^\/+/, "").replace(/\/+$/, "");
41
- const trimmedPath = pathname.replace(/^\/+/, "");
42
-
43
- if (!trimmedHost) {
44
- throw new Error("plan:// URL requires a session or plan identifier");
45
- }
46
-
47
- if (trimmedPath) {
48
- return path.join(trimmedHost, trimmedPath);
49
- }
50
-
51
- if (trimmedHost.endsWith(".md")) {
52
- return trimmedHost;
53
- }
54
-
55
- return path.join(trimmedHost, "plan.md");
56
- }
57
-
58
- export function resolvePlanUrlToPath(input: string | InternalUrl, options: PlanProtocolOptions): string {
59
- const url = typeof input === "string" ? parsePlanUrl(input) : input;
60
- const host = url.rawHost || url.hostname;
61
- const relativePath = normalizeRelativePath(host, url.pathname ?? "");
62
- const plansDir = path.resolve(options.getPlansDirectory(options.cwd));
63
- const resolved = path.resolve(plansDir, relativePath);
64
-
65
- if (resolved !== plansDir && !resolved.startsWith(`${plansDir}${path.sep}`)) {
66
- throw new Error("plan:// URL escapes the plans directory");
67
- }
68
-
69
- return resolved;
70
- }
71
-
72
- export class PlanProtocolHandler implements ProtocolHandler {
73
- readonly scheme = "plan";
74
-
75
- constructor(private readonly options: PlanProtocolOptions) {}
76
-
77
- async resolve(url: InternalUrl): Promise<InternalResource> {
78
- const planPath = resolvePlanUrlToPath(url, this.options);
79
- try {
80
- const content = await Bun.file(planPath).text();
81
- return {
82
- url: url.href,
83
- content,
84
- contentType: "text/markdown",
85
- size: Buffer.byteLength(content, "utf-8"),
86
- sourcePath: planPath,
87
- };
88
- } catch (error) {
89
- if (isEnoent(error)) {
90
- throw new Error(`Plan file not found: ${url.href}`);
91
- }
92
- throw error;
93
- }
94
- }
95
- }