@ryan_nookpi/pi-extension-todo-write-overlay 0.2.4 → 0.3.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 (3) hide show
  1. package/README.md +20 -0
  2. package/index.ts +118 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -51,6 +51,8 @@ pi install npm:@ryan_nookpi/pi-extension-todo-write-overlay
51
51
 
52
52
  ## 사용 예
53
53
 
54
+ 처음 목록을 만들거나 큰 폭으로 재편할 때는 `op` 없이(기본 `replace`) 전체를 보냅니다.
55
+
54
56
  ```json
55
57
  {
56
58
  "todos": [
@@ -61,6 +63,24 @@ pi install npm:@ryan_nookpi/pi-extension-todo-write-overlay
61
63
  }
62
64
  ```
63
65
 
66
+ 이미 목록이 있고 일부만 바꿀 때는 `op: "patch"`로 변경분만 보냅니다. 작업 수가 많을수록 args 크기가 변경분에만 비례해 저렴합니다.
67
+
68
+ ```json
69
+ {
70
+ "op": "patch",
71
+ "set": [
72
+ { "id": "task-1", "status": "completed" },
73
+ { "id": "task-2", "status": "in_progress", "activeForm": "구현 중" }
74
+ ],
75
+ "add": [{ "content": "문서화", "status": "pending" }],
76
+ "remove": ["task-5"]
77
+ }
78
+ ```
79
+
80
+ - `set`/`add`/`remove`는 함께 쓸 수 있으며 remove → set → add 순서로 적용됩니다.
81
+ - `id`는 직전 `todo_write` 결과에 표시된 `task-N` 값을 참조합니다. 존재하지 않는 id는 결과 텍스트에 경고로 표시되고 무시됩니다.
82
+ - `add`로 추가되는 항목은 기존 id와 겹치지 않는 새 `task-N` id를 받습니다.
83
+
64
84
  ## 상태 필드
65
85
 
66
86
  - `content`: 작업 설명
package/index.ts CHANGED
@@ -39,14 +39,32 @@ const InputTask = Type.Object({
39
39
  notes: Type.Optional(Type.String({ description: "추가 맥락 또는 메모" })),
40
40
  });
41
41
 
42
+ const PatchSetTask = Type.Object({
43
+ id: Type.String({ description: "갱신할 작업 id. 직전 todo_write 결과에 표시된 task-N 값을 사용" }),
44
+ content: Type.Optional(Type.String({ description: "작업 설명 (변경 시에만)" })),
45
+ status: Type.Optional(StatusEnum),
46
+ activeForm: Type.Optional(Type.String({ description: "진행 중 표시용 현재진행형 문구" })),
47
+ notes: Type.Optional(Type.String({ description: "추가 맥락 또는 메모" })),
48
+ });
49
+
42
50
  const TodoWriteParams = Type.Object(
43
51
  {
44
- todos: Type.Array(InputTask, { description: "업데이트된 todo 목록" }),
52
+ op: Type.Optional(
53
+ Type.Union([Type.Literal("replace"), Type.Literal("patch")], {
54
+ description:
55
+ "replace(기본): todos로 전체 목록 교체. patch: set/add/remove로 변경분만 전달. 항목이 많을 때는 patch가 저렴합니다.",
56
+ }),
57
+ ),
58
+ todos: Type.Optional(Type.Array(InputTask, { description: "op=replace일 때: 전체 todo 목록" })),
59
+ set: Type.Optional(Type.Array(PatchSetTask, { description: "op=patch일 때: 기존 항목의 부분 갱신 목록" })),
60
+ add: Type.Optional(Type.Array(InputTask, { description: "op=patch일 때: 새로 추가할 항목 목록" })),
61
+ remove: Type.Optional(Type.Array(Type.String(), { description: "op=patch일 때: 제거할 작업 id 목록" })),
45
62
  },
46
63
  { additionalProperties: true },
47
64
  );
48
65
 
49
- type TodoWriteParamsType = Static<typeof TodoWriteParams>;
66
+ type InputTaskType = Static<typeof InputTask>;
67
+ type PatchSetTaskType = Static<typeof PatchSetTask>;
50
68
 
51
69
  const todoStateStore = new Map<string, TodoState>();
52
70
  const todoOverlayStore = new Map<string, TodoOverlayRecord>();
@@ -130,7 +148,7 @@ export function getTodoOverlayVisibility(
130
148
  };
131
149
  }
132
150
 
133
- export function applyTodoWrite(todos: TodoWriteParamsType["todos"]): {
151
+ export function applyTodoWrite(todos: InputTaskType[]): {
134
152
  state: TodoState;
135
153
  } {
136
154
  const tasks: TodoTask[] = todos.map((todo, index) => ({
@@ -144,6 +162,66 @@ export function applyTodoWrite(todos: TodoWriteParamsType["todos"]): {
144
162
  return { state: { tasks } };
145
163
  }
146
164
 
165
+ function nextTaskId(tasks: TodoTask[]): string {
166
+ let max = 0;
167
+ for (const task of tasks) {
168
+ const match = /^task-(\d+)$/.exec(task.id);
169
+ if (match?.[1]) max = Math.max(max, Number.parseInt(match[1], 10));
170
+ }
171
+ return `task-${max + 1}`;
172
+ }
173
+
174
+ export type TodoPatch = {
175
+ set?: PatchSetTaskType[];
176
+ add?: InputTaskType[];
177
+ remove?: string[];
178
+ };
179
+
180
+ export function applyTodoPatch(
181
+ state: TodoState,
182
+ patch: TodoPatch,
183
+ ): {
184
+ state: TodoState;
185
+ warnings: string[];
186
+ } {
187
+ const tasks = cloneTasks(state.tasks);
188
+ const warnings: string[] = [];
189
+
190
+ for (const id of patch.remove ?? []) {
191
+ const index = tasks.findIndex((task) => task.id === id);
192
+ if (index === -1) {
193
+ warnings.push(`제거할 항목을 찾지 못했습니다: ${id}`);
194
+ continue;
195
+ }
196
+ tasks.splice(index, 1);
197
+ }
198
+
199
+ for (const update of patch.set ?? []) {
200
+ const task = tasks.find((candidate) => candidate.id === update.id);
201
+ if (!task) {
202
+ warnings.push(`갱신할 항목을 찾지 못했습니다: ${update.id}`);
203
+ continue;
204
+ }
205
+ if (update.content !== undefined) task.content = update.content;
206
+ if (update.status !== undefined) task.status = update.status;
207
+ if (update.activeForm !== undefined) task.activeForm = update.activeForm;
208
+ if (update.notes !== undefined) task.notes = update.notes;
209
+ }
210
+
211
+ for (const todo of patch.add ?? []) {
212
+ tasks.push({
213
+ id: nextTaskId(tasks),
214
+ content: todo.content,
215
+ status: todo.status,
216
+ activeForm: todo.activeForm,
217
+ notes: todo.notes,
218
+ });
219
+ }
220
+
221
+ normalizeInProgressTask(tasks);
222
+ return { state: { tasks }, warnings };
223
+ }
224
+
147
225
  export function renderTodoOverlayPlainLines(state: TodoState): string[] {
148
226
  return state.tasks.map((task) => {
149
227
  const marker = task.status === "completed" ? "✓" : task.status === "in_progress" ? "→" : "○";
@@ -322,6 +400,10 @@ function incrementTodoTurn(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">
322
400
  todoTurnStore.set(key, getTodoTurn(key) + 1);
323
401
  }
324
402
 
403
+ export function shouldIncrementTodoTurn(message: { role: string; stopReason?: string }): boolean {
404
+ return message.role === "assistant" && message.stopReason !== "toolUse";
405
+ }
406
+
325
407
  function setTodoOverlayAgentRunning(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">, running: boolean): void {
326
408
  const key = getTodoStateKey(ctx);
327
409
  todoOverlayAgentRunningStore.set(key, running);
@@ -593,6 +675,14 @@ export default function todoWriteOverlayExtension(pi: ExtensionAPI): void {
593
675
  - 완전히 끝난 일만 completed로 표시하고, 막혔으면 in_progress 유지
594
676
  - 요구사항이 바뀌면 계속 진행하기 전에 todo 목록부터 갱신
595
677
 
678
+ ## 업데이트 방식 (op)
679
+ - 처음 목록을 만들거나 큰 폭으로 재편할 때만 op=replace(기본)로 todos 전체를 보냅니다.
680
+ - 이미 목록이 있고 일부만 바뀌면 op=patch로 변경분만 보내세요. 항목 수가 많을수록 강력히 권장됩니다.
681
+ - set: 기존 항목의 상태/내용 부분 갱신. 예: 하나 완료 + 다음 시작 => set: [{id:"task-1", status:"completed"}, {id:"task-2", status:"in_progress"}]
682
+ - add: 새 항목 추가
683
+ - remove: 항목 id 제거
684
+ - id는 임의로 지어내지 말고, 직전 todo_write 결과에 표시된 task-N 값을 그대로 참조하세요.
685
+
596
686
  ## 필드 설명
597
687
  - content: 명령형 작업 문구 (예: "테스트 실행")
598
688
  - status: pending | in_progress | completed
@@ -600,14 +690,31 @@ export default function todoWriteOverlayExtension(pi: ExtensionAPI): void {
600
690
  - notes: (선택) 추가 맥락`,
601
691
  parameters: TodoWriteParams,
602
692
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
603
- const applied = applyTodoWrite(params.todos);
604
- const summary = renderTodoWriteSummary(applied.state);
605
- writeTodoWriteState(ctx, applied.state);
606
- persistTodoWriteStateEntry(pi, applied.state);
693
+ const op = params.op ?? "replace";
694
+ let state: TodoState;
695
+ let warnings: string[] = [];
696
+ if (op === "patch") {
697
+ const result = applyTodoPatch(readTodoWriteState(ctx), {
698
+ set: params.set,
699
+ add: params.add,
700
+ remove: params.remove,
701
+ });
702
+ state = result.state;
703
+ warnings = result.warnings;
704
+ } else {
705
+ state = applyTodoWrite(params.todos ?? []).state;
706
+ }
707
+ const summary = renderTodoWriteSummary(state);
708
+ writeTodoWriteState(ctx, state);
709
+ persistTodoWriteStateEntry(pi, state);
607
710
  await syncTodoOverlay(ctx, pi);
711
+ const text =
712
+ warnings.length > 0
713
+ ? `${summary}\n\n주의:\n${warnings.map((warning) => ` - ${warning}`).join("\n")}`
714
+ : summary;
608
715
  return {
609
- content: [{ type: "text" as const, text: summary }],
610
- details: { tasks: applied.state.tasks, summary },
716
+ content: [{ type: "text" as const, text }],
717
+ details: { tasks: state.tasks, summary, warnings },
611
718
  };
612
719
  },
613
720
  renderResult(result, { expanded }, theme) {
@@ -686,7 +793,8 @@ export default function todoWriteOverlayExtension(pi: ExtensionAPI): void {
686
793
  );
687
794
  });
688
795
 
689
- pi.on("message_end", async (_event, ctx) => {
796
+ pi.on("message_end", async (event, ctx) => {
797
+ if (!shouldIncrementTodoTurn(event.message)) return;
690
798
  incrementTodoTurn(ctx);
691
799
  await syncTodoOverlay(ctx, pi);
692
800
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-todo-write-overlay",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Top-right overlay todo_write tool extension for pi.",
5
5
  "license": "MIT",
6
6
  "repository": {