@ryan_nookpi/pi-extension-todo-write-overlay 0.1.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.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @ryan_nookpi/pi-extension-todo-write-overlay
2
+
3
+ pi가 현재 세션에서 구조화된 작업 목록을 만들고 갱신할 수 있게 해주는 `todo_write` 익스텐션입니다. 할 일 목록은 입력창 위젯이 아니라 **우측 상단 passive overlay**로 표시됩니다.
4
+
5
+ ![todo_write overlay in chat](./assets/todo-overlay-chat.png)
6
+
7
+ ![todo_write overlay progress](./assets/todo-overlay-progress.png)
8
+
9
+ ## 설치
10
+
11
+ ```bash
12
+ pi install npm:@ryan_nookpi/pi-extension-todo-write-overlay
13
+ ```
14
+
15
+ > `@ryan_nookpi/pi-extension-todo-write`와 같은 `todo_write` 도구 이름을 사용합니다. 두 익스텐션을 동시에 설치하지 않는 것을 권장합니다.
16
+
17
+ ## 제공 기능
18
+
19
+ - `todo_write` 도구 등록
20
+ - 세션 단위 todo 상태 저장 및 복원
21
+ - 우측 상단 오버레이 렌더링
22
+ - 입력 포커스를 뺏지 않는 `nonCapturing` overlay 사용
23
+ - 진행 중 작업 스피너 표시
24
+ - 완료/진행/대기 상태별 색상과 아이콘 표시
25
+ - `notes`는 상태에는 보존하지만 오버레이에는 표시하지 않음
26
+ - compaction 이후 남은 todo reminder 주입
27
+
28
+ ## 표시 방식
29
+
30
+ 오버레이는 pi TUI의 `ctx.ui.custom(..., { overlay: true })`를 사용합니다.
31
+
32
+ - 위치: `top-right`
33
+ - 너비: 42 columns
34
+ - 여백: 위 1줄, 오른쪽 2 columns
35
+ - 표시 조건: 터미널 너비 70 columns 이상
36
+ - 입력 처리: `nonCapturing: true`
37
+
38
+ 기존 `todo-write`의 `완료 +2` 같은 완료 항목 접기 로직은 사용하지 않습니다. 완료된 항목도 모두 그대로 표시합니다.
39
+
40
+ ## 사용 예
41
+
42
+ ```json
43
+ {
44
+ "todos": [
45
+ { "content": "설계 정리", "status": "completed" },
46
+ { "content": "구현", "status": "in_progress", "activeForm": "구현 중" },
47
+ { "content": "검증", "status": "pending" }
48
+ ]
49
+ }
50
+ ```
51
+
52
+ ## 상태 필드
53
+
54
+ - `content`: 작업 설명
55
+ - `status`: `pending` | `in_progress` | `completed`
56
+ - `activeForm`: 진행 중일 때 오버레이에 보여줄 현재진행형 문구
57
+ - `notes`: 보조 메모
58
+
59
+ ## 동작 메모
60
+
61
+ - `in_progress`가 여러 개 들어오면 첫 번째만 유지하고 나머지는 `pending`으로 정규화합니다.
62
+ - `in_progress`가 없고 `pending`이 있으면 첫 번째 `pending`을 자동으로 `in_progress`로 올립니다.
63
+ - 모든 항목이 완료되면 잠시 표시한 뒤 자동으로 숨깁니다.
Binary file
Binary file
package/index.ts ADDED
@@ -0,0 +1,647 @@
1
+ import { StringEnum } from "@mariozechner/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
3
+ import type { OverlayHandle, TUI } from "@mariozechner/pi-tui";
4
+ import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
5
+ import { type Static, Type } from "@sinclair/typebox";
6
+
7
+ type TodoStatus = "pending" | "in_progress" | "completed";
8
+
9
+ type TodoTask = {
10
+ id: string;
11
+ content: string;
12
+ status: TodoStatus;
13
+ activeForm?: string;
14
+ notes?: string;
15
+ };
16
+
17
+ type TodoState = {
18
+ tasks: TodoTask[];
19
+ };
20
+
21
+ type TodoOverlayRecord = {
22
+ opening: boolean;
23
+ component?: TodoOverlayComponent;
24
+ handle?: OverlayHandle;
25
+ close?: () => void;
26
+ };
27
+
28
+ const StatusEnum = StringEnum(["pending", "in_progress", "completed"] as const, {
29
+ description: "작업 상태",
30
+ });
31
+
32
+ const InputTask = Type.Object({
33
+ content: Type.String({ description: "작업 설명" }),
34
+ status: StatusEnum,
35
+ activeForm: Type.Optional(
36
+ Type.String({
37
+ description: "진행 중 표시용 현재진행형 문구 (예: '테스트 실행 중')",
38
+ }),
39
+ ),
40
+ notes: Type.Optional(Type.String({ description: "추가 맥락 또는 메모" })),
41
+ });
42
+
43
+ const TodoWriteParams = Type.Object(
44
+ {
45
+ todos: Type.Array(InputTask, { description: "업데이트된 todo 목록" }),
46
+ },
47
+ { additionalProperties: true },
48
+ );
49
+
50
+ type TodoWriteParamsType = Static<typeof TodoWriteParams>;
51
+
52
+ const todoStateStore = new Map<string, TodoState>();
53
+ const todoOverlayStore = new Map<string, TodoOverlayRecord>();
54
+ const todoOverlayMetaStore = new Map<string, { completedAt?: number; completedTurn?: number }>();
55
+ const todoOverlayAgentRunningStore = new Map<string, boolean>();
56
+ const todoTurnStore = new Map<string, number>();
57
+ const TODO_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
58
+ const TODO_SPINNER_INTERVAL_MS = 120;
59
+ const TODO_HIDE_COMPLETED_AFTER_TURNS = 2;
60
+ const TODO_HIDE_COMPLETED_AFTER_MS = 90_000;
61
+ const TODO_STATE_ENTRY_TYPE = "todo-write-overlay-state";
62
+ const TODO_COMPACTION_REMINDER_TYPE = "todo-write-overlay-compaction-reminder";
63
+
64
+ function createEmptyState(): TodoState {
65
+ return { tasks: [] };
66
+ }
67
+
68
+ function getTodoStateKey(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): string {
69
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
70
+ return sessionFile ? `session:${sessionFile}` : `cwd:${ctx.cwd}`;
71
+ }
72
+
73
+ function cloneTasks(tasks: TodoTask[]): TodoTask[] {
74
+ return tasks.map((task) => ({ ...task }));
75
+ }
76
+
77
+ function cloneState(state: TodoState): TodoState {
78
+ return { tasks: cloneTasks(state.tasks) };
79
+ }
80
+
81
+ function normalizeInProgressTask(tasks: TodoTask[]): void {
82
+ if (tasks.length === 0) return;
83
+
84
+ const inProgressTasks = tasks.filter((task) => task.status === "in_progress");
85
+ if (inProgressTasks.length > 1) {
86
+ for (const task of inProgressTasks.slice(1)) {
87
+ task.status = "pending";
88
+ }
89
+ }
90
+
91
+ if (inProgressTasks.length > 0) return;
92
+
93
+ const firstPendingTask = tasks.find((task) => task.status === "pending");
94
+ if (firstPendingTask) firstPendingTask.status = "in_progress";
95
+ }
96
+
97
+ function hasRemainingTasks(state: TodoState): boolean {
98
+ return state.tasks.some((task) => task.status === "pending" || task.status === "in_progress");
99
+ }
100
+
101
+ function hasInProgressTask(state: TodoState): boolean {
102
+ return state.tasks.some((task) => task.status === "in_progress");
103
+ }
104
+
105
+ type TodoOverlayVisibility = {
106
+ hidden: boolean;
107
+ completionGraceActive: boolean;
108
+ meta?: { completedAt: number; completedTurn: number };
109
+ };
110
+
111
+ export function getTodoOverlayVisibility(
112
+ state: TodoState,
113
+ meta: { completedAt?: number; completedTurn?: number } | undefined,
114
+ currentTurn: number,
115
+ now: number,
116
+ ): TodoOverlayVisibility {
117
+ if (state.tasks.length === 0) return { hidden: true, completionGraceActive: false };
118
+ if (hasRemainingTasks(state)) return { hidden: false, completionGraceActive: false };
119
+
120
+ const completedTurn = meta?.completedTurn ?? currentTurn;
121
+ const completedAt = meta?.completedAt ?? now;
122
+ const elapsedTurns = Math.max(0, currentTurn - completedTurn);
123
+ const elapsedMs = Math.max(0, now - completedAt);
124
+ const hidden = elapsedTurns >= TODO_HIDE_COMPLETED_AFTER_TURNS || elapsedMs >= TODO_HIDE_COMPLETED_AFTER_MS;
125
+
126
+ return {
127
+ hidden,
128
+ completionGraceActive: !hidden,
129
+ meta: { completedAt, completedTurn },
130
+ };
131
+ }
132
+
133
+ export function applyTodoWrite(todos: TodoWriteParamsType["todos"]): {
134
+ state: TodoState;
135
+ } {
136
+ const tasks: TodoTask[] = todos.map((todo, index) => ({
137
+ id: `task-${index + 1}`,
138
+ content: todo.content,
139
+ status: todo.status,
140
+ activeForm: todo.activeForm,
141
+ notes: todo.notes,
142
+ }));
143
+ normalizeInProgressTask(tasks);
144
+ return { state: { tasks } };
145
+ }
146
+
147
+ export function renderTodoOverlayPlainLines(state: TodoState): string[] {
148
+ return state.tasks.map((task) => {
149
+ const marker = task.status === "completed" ? "✓" : task.status === "in_progress" ? "→" : "○";
150
+ const displayText = task.status === "in_progress" && task.activeForm ? task.activeForm : task.content;
151
+ return `${marker} ${displayText}`;
152
+ });
153
+ }
154
+
155
+ export function renderTodoWriteSummary(state: TodoState): string {
156
+ if (state.tasks.length === 0) return "할 일 목록을 비웠습니다.";
157
+
158
+ const remainingTasks = state.tasks.filter((task) => task.status === "pending" || task.status === "in_progress");
159
+ const doneCount = state.tasks.filter((task) => task.status === "completed").length;
160
+
161
+ const lines: string[] = [];
162
+ if (remainingTasks.length === 0) {
163
+ lines.push("남은 항목: 없음.");
164
+ } else {
165
+ lines.push(`남은 항목 (${remainingTasks.length}개):`);
166
+ for (const task of remainingTasks) {
167
+ lines.push(` - ${task.id} ${task.content} [${task.status}]`);
168
+ }
169
+ }
170
+
171
+ lines.push(`진행률: ${doneCount}/${state.tasks.length} 완료`);
172
+
173
+ for (const task of state.tasks) {
174
+ const marker = task.status === "completed" ? "✓" : task.status === "in_progress" ? "→" : "○";
175
+ lines.push(` ${marker} ${task.id} ${task.content}`);
176
+ }
177
+
178
+ return lines.join("\n");
179
+ }
180
+
181
+ function buildTodoTurnContext(state: TodoState): string | null {
182
+ if (state.tasks.length === 0) return null;
183
+
184
+ const summary = renderTodoWriteSummary(state);
185
+ const activeTask = state.tasks.find((task) => task.status === "in_progress");
186
+ const activeLine = activeTask
187
+ ? [
188
+ `현재 작업: ${activeTask.id} ${activeTask.activeForm ?? activeTask.content}`,
189
+ "이 작업이 끝났다면 다른 도구 호출이나 응답보다 먼저 todo_write로 상태를 갱신하세요.",
190
+ ]
191
+ : hasRemainingTasks(state)
192
+ ? [
193
+ "남은 작업이 있지만 현재 in_progress 상태의 항목이 없습니다. 계속 진행하기 전에 todo_write로 다음 활성 작업을 지정하세요.",
194
+ ]
195
+ : [];
196
+
197
+ return [
198
+ "[todo-reminder] 현재 todo_write 상태 스냅샷",
199
+ "출처: todo_write_overlay 도구가 유지하는 세션 메모리 상태입니다.",
200
+ "현재 턴에서는 이 내용을 가장 최신의 기준 상태로 간주하세요.",
201
+ "이 스냅샷과 모순되게 설명하지 말고, 상태가 달라졌다면 먼저 todo_write를 업데이트하세요.",
202
+ "",
203
+ summary,
204
+ ...(activeLine.length > 0 ? ["", ...activeLine] : []),
205
+ ].join("\n");
206
+ }
207
+
208
+ type TodoStateEntryData = {
209
+ tasks: TodoTask[];
210
+ updatedAt: number;
211
+ };
212
+
213
+ function persistTodoWriteStateEntry(pi: Pick<ExtensionAPI, "appendEntry">, state: TodoState): void {
214
+ pi.appendEntry<TodoStateEntryData>(TODO_STATE_ENTRY_TYPE, {
215
+ tasks: cloneTasks(state.tasks),
216
+ updatedAt: Date.now(),
217
+ });
218
+ }
219
+
220
+ function clearTodoWriteState(
221
+ ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
222
+ pi: Pick<ExtensionAPI, "appendEntry">,
223
+ ): void {
224
+ const empty = createEmptyState();
225
+ writeTodoWriteState(ctx, empty);
226
+ persistTodoWriteStateEntry(pi, empty);
227
+ }
228
+
229
+ type PersistedTodoStatus = TodoStatus | "abandoned";
230
+
231
+ type PersistedTodoTask = {
232
+ id: string;
233
+ content: string;
234
+ status: PersistedTodoStatus;
235
+ activeForm?: string;
236
+ notes?: string;
237
+ };
238
+
239
+ type PersistedTodoStateEntryData = {
240
+ tasks: PersistedTodoTask[];
241
+ updatedAt: number;
242
+ };
243
+
244
+ function _isPersistedStatus(value: unknown): value is PersistedTodoStatus {
245
+ return value === "pending" || value === "in_progress" || value === "completed" || value === "abandoned";
246
+ }
247
+
248
+ function isPersistedTodoTask(value: unknown): value is PersistedTodoTask {
249
+ if (!value || typeof value !== "object") return false;
250
+ const candidate = value as Partial<PersistedTodoTask>;
251
+ return (
252
+ typeof candidate.id === "string" && typeof candidate.content === "string" && _isPersistedStatus(candidate.status)
253
+ );
254
+ }
255
+
256
+ function migrateLegacyTasks(tasks: PersistedTodoTask[]): TodoTask[] {
257
+ const migrated = tasks.map((task) => ({
258
+ id: task.id,
259
+ content: task.content,
260
+ status: task.status === "abandoned" ? "completed" : task.status,
261
+ activeForm: task.activeForm,
262
+ notes: task.notes,
263
+ }));
264
+ normalizeInProgressTask(migrated);
265
+ return migrated;
266
+ }
267
+
268
+ function isPersistedTodoStateEntryData(value: unknown): value is PersistedTodoStateEntryData {
269
+ if (!value || typeof value !== "object") return false;
270
+ const candidate = value as Partial<PersistedTodoStateEntryData>;
271
+ return (
272
+ Array.isArray(candidate.tasks) &&
273
+ typeof candidate.updatedAt === "number" &&
274
+ candidate.tasks.every((task) => isPersistedTodoTask(task))
275
+ );
276
+ }
277
+
278
+ export function restoreTodoWriteState(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): TodoState {
279
+ const branch = ctx.sessionManager.getBranch?.() ?? [];
280
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
281
+ const entry = branch[index];
282
+ if (entry?.type !== "custom" || entry.customType !== TODO_STATE_ENTRY_TYPE) continue;
283
+ if (isPersistedTodoStateEntryData(entry.data)) {
284
+ const restored = { tasks: migrateLegacyTasks(entry.data.tasks) };
285
+ writeTodoWriteState(ctx, restored);
286
+ return restored;
287
+ }
288
+ }
289
+
290
+ const empty = createEmptyState();
291
+ writeTodoWriteState(ctx, empty);
292
+ return empty;
293
+ }
294
+
295
+ export function buildPostCompactionTodoReminder(state: TodoState): string | null {
296
+ if (!hasRemainingTasks(state)) return null;
297
+ return [
298
+ "[todo-reminder] compaction 이후에도 todo_write에 아직 남은 항목이 있습니다.",
299
+ "다음 응답/도구 호출 전에 이 상태를 이어서 사용하세요.",
300
+ "",
301
+ renderTodoWriteSummary(state),
302
+ ].join("\n");
303
+ }
304
+
305
+ function readTodoWriteState(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): TodoState {
306
+ const key = getTodoStateKey(ctx);
307
+ const state = todoStateStore.get(key) ?? createEmptyState();
308
+ return { tasks: cloneTasks(state.tasks) };
309
+ }
310
+
311
+ function writeTodoWriteState(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">, state: TodoState): void {
312
+ const key = getTodoStateKey(ctx);
313
+ todoStateStore.set(key, { tasks: cloneTasks(state.tasks) });
314
+ }
315
+
316
+ function getTodoTurn(key: string): number {
317
+ return todoTurnStore.get(key) ?? 0;
318
+ }
319
+
320
+ function incrementTodoTurn(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): void {
321
+ const key = getTodoStateKey(ctx);
322
+ todoTurnStore.set(key, getTodoTurn(key) + 1);
323
+ }
324
+
325
+ function setTodoOverlayAgentRunning(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">, running: boolean): void {
326
+ const key = getTodoStateKey(ctx);
327
+ todoOverlayAgentRunningStore.set(key, running);
328
+ }
329
+
330
+ function hideTodoOverlay(key: string): void {
331
+ const record = todoOverlayStore.get(key);
332
+ if (!record) return;
333
+ record.close?.();
334
+ record.handle?.hide();
335
+ record.component?.dispose();
336
+ todoOverlayStore.delete(key);
337
+ }
338
+
339
+ function padAnsi(text: string, width: number): string {
340
+ const clipped = truncateToWidth(text, width, "...", true);
341
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
342
+ }
343
+
344
+ class TodoOverlayComponent {
345
+ private state: TodoState;
346
+ private agentRunning: boolean;
347
+ private timer: ReturnType<typeof setInterval> | undefined;
348
+ private disposed = false;
349
+
350
+ constructor(
351
+ private tui: TUI,
352
+ private theme: Theme,
353
+ state: TodoState,
354
+ agentRunning: boolean,
355
+ ) {
356
+ this.state = cloneState(state);
357
+ this.agentRunning = agentRunning;
358
+ this.syncTimer();
359
+ }
360
+
361
+ setState(state: TodoState): void {
362
+ this.state = cloneState(state);
363
+ this.syncTimer();
364
+ this.tui.requestRender();
365
+ }
366
+
367
+ setAgentRunning(running: boolean): void {
368
+ this.agentRunning = running;
369
+ this.syncTimer();
370
+ this.tui.requestRender();
371
+ }
372
+
373
+ invalidate(): void {
374
+ this.tui.requestRender();
375
+ }
376
+
377
+ render(width: number): string[] {
378
+ const innerWidth = Math.max(1, width - 2);
379
+ const isActivelyRunning = this.agentRunning && hasInProgressTask(this.state);
380
+ const border = (text: string) => {
381
+ const colored = this.theme.fg(
382
+ isActivelyRunning ? "accent" : hasRemainingTasks(this.state) ? "borderAccent" : "borderMuted",
383
+ text,
384
+ );
385
+ return isActivelyRunning ? this.theme.bold(colored) : colored;
386
+ };
387
+ const row = (text: string) => `${border("│")}${padAnsi(text, innerWidth)}${border("│")}`;
388
+ const doneCount = this.state.tasks.filter((task) => task.status === "completed").length;
389
+ const totalCount = this.state.tasks.length;
390
+ const title = this.theme.fg("accent", this.theme.bold(" TODO "));
391
+ const progress = totalCount === 0 ? "0/0" : `${doneCount}/${totalCount}`;
392
+ const progressText = this.theme.fg("dim", ` ${progress} 완료 `);
393
+ const titleWidth = visibleWidth(title) + visibleWidth(progressText);
394
+ const titlePad = Math.max(0, innerWidth - titleWidth);
395
+ const lines = [`${border("╭")}${title}${border("─".repeat(titlePad))}${progressText}${border("╮")}`];
396
+
397
+ if (this.state.tasks.length === 0) {
398
+ lines.push(row(` ${this.theme.fg("dim", "할 일 없음")}`));
399
+ } else {
400
+ for (const task of this.state.tasks) {
401
+ lines.push(row(this.renderTaskLine(task)));
402
+ }
403
+ }
404
+
405
+ lines.push(`${border("╰")}${border("─".repeat(innerWidth))}${border("╯")}`);
406
+ return lines;
407
+ }
408
+
409
+ dispose(): void {
410
+ if (this.disposed) return;
411
+ this.disposed = true;
412
+ if (this.timer) {
413
+ clearInterval(this.timer);
414
+ this.timer = undefined;
415
+ }
416
+ }
417
+
418
+ private renderTaskLine(task: TodoTask): string {
419
+ const displayText = task.status === "in_progress" && task.activeForm ? task.activeForm : task.content;
420
+ if (task.status === "completed") {
421
+ return ` ${this.theme.fg("success", "✓")} ${this.theme.fg("dim", this.theme.strikethrough(displayText))}`;
422
+ }
423
+ if (task.status === "in_progress") {
424
+ const marker = this.agentRunning ? this.currentSpinner() : "→";
425
+ return ` ${this.theme.fg("accent", marker)} ${this.theme.fg("accent", this.theme.bold(displayText))}`;
426
+ }
427
+ return ` ${this.theme.fg("muted", "○")} ${this.theme.fg("toolOutput", displayText)}`;
428
+ }
429
+
430
+ private currentSpinner(): string {
431
+ return TODO_SPINNER_FRAMES[Math.floor(Date.now() / TODO_SPINNER_INTERVAL_MS) % TODO_SPINNER_FRAMES.length] ?? "•";
432
+ }
433
+
434
+ private syncTimer(): void {
435
+ const shouldRun = !this.disposed && this.agentRunning && hasInProgressTask(this.state);
436
+ if (shouldRun && !this.timer) {
437
+ this.timer = setInterval(() => this.tui.requestRender(), TODO_SPINNER_INTERVAL_MS);
438
+ return;
439
+ }
440
+ if (!shouldRun && this.timer) {
441
+ clearInterval(this.timer);
442
+ this.timer = undefined;
443
+ }
444
+ }
445
+ }
446
+
447
+ function showOrUpdateTodoOverlay(ctx: ExtensionContext, key: string, state: TodoState): void {
448
+ const agentRunning = todoOverlayAgentRunningStore.get(key) ?? false;
449
+ const record = todoOverlayStore.get(key);
450
+ if (record?.component) {
451
+ record.component.setState(state);
452
+ record.component.setAgentRunning(agentRunning);
453
+ return;
454
+ }
455
+ if (record?.opening) return;
456
+
457
+ todoOverlayStore.set(key, { opening: true });
458
+ const initialState = cloneState(state);
459
+ const overlayPromise = ctx.ui.custom<void>(
460
+ (tui, theme, _keybindings, done) => {
461
+ const component = new TodoOverlayComponent(tui, theme, initialState, agentRunning);
462
+ const current = todoOverlayStore.get(key) ?? { opening: false };
463
+ todoOverlayStore.set(key, { ...current, opening: false, component, close: done });
464
+ return component;
465
+ },
466
+ {
467
+ overlay: true,
468
+ overlayOptions: {
469
+ anchor: "top-right",
470
+ width: 42,
471
+ maxHeight: "60%",
472
+ margin: { top: 1, right: 2 },
473
+ nonCapturing: true,
474
+ visible: (termWidth) => termWidth >= 70,
475
+ },
476
+ onHandle: (handle) => {
477
+ const current = todoOverlayStore.get(key) ?? { opening: false };
478
+ todoOverlayStore.set(key, { ...current, handle });
479
+ },
480
+ },
481
+ );
482
+ void overlayPromise
483
+ .finally(() => {
484
+ const current = todoOverlayStore.get(key);
485
+ current?.component?.dispose();
486
+ todoOverlayStore.delete(key);
487
+ })
488
+ .catch(() => {});
489
+ }
490
+
491
+ async function syncTodoOverlay(ctx: ExtensionContext, pi: Pick<ExtensionAPI, "appendEntry">): Promise<void> {
492
+ if (!ctx.hasUI) return;
493
+
494
+ const key = getTodoStateKey(ctx);
495
+ const state = readTodoWriteState(ctx);
496
+ const visibility = getTodoOverlayVisibility(state, todoOverlayMetaStore.get(key), getTodoTurn(key), Date.now());
497
+
498
+ if (visibility.meta) {
499
+ todoOverlayMetaStore.set(key, visibility.meta);
500
+ } else {
501
+ todoOverlayMetaStore.delete(key);
502
+ }
503
+
504
+ if (visibility.hidden || state.tasks.length === 0) {
505
+ if (visibility.hidden && state.tasks.length > 0) {
506
+ clearTodoWriteState(ctx, pi);
507
+ todoOverlayMetaStore.delete(key);
508
+ }
509
+ hideTodoOverlay(key);
510
+ return;
511
+ }
512
+
513
+ showOrUpdateTodoOverlay(ctx, key, state);
514
+ }
515
+
516
+ export default function todoWriteOverlayExtension(pi: ExtensionAPI): void {
517
+ pi.registerTool({
518
+ name: "todo_write",
519
+ label: "할 일 관리",
520
+ description: `현재 코딩 세션의 구조화된 작업 목록을 만들고 관리합니다. 진행 상황을 추적하고, 복잡한 요청을 단계로 나누고, 사용자에게 현재 무엇을 하고 있는지 우측 상단 오버레이로 보여줄 때 사용하세요.
521
+
522
+ ## 언제 사용할까
523
+ - 3단계 이상의 복잡한 멀티스텝 작업
524
+ - 사용자가 여러 작업을 한 번에 요청한 경우
525
+ - 구현/디버깅 전에 계획 정리가 필요한 비단순 작업
526
+
527
+ ## 언제 쓰지 말까
528
+ - 단순한 한 가지 작업이면 바로 수행
529
+ - 3단계 미만으로 끝나는 아주 간단한 작업
530
+ - 순수 대화형/정보 제공성 응답만 필요한 경우
531
+
532
+ ## 규칙
533
+ - 가능하면 todo 내용은 한글로 간결하게 작성
534
+ - 작업하면서 상태를 실시간으로 갱신
535
+ - 작업이 끝나면 즉시 completed로 변경하고 몰아서 처리하지 않기
536
+ - in_progress 상태는 정확히 하나만 유지
537
+ - 새 작업을 시작하기 전에 현재 작업을 정리
538
+ - 더 이상 의미 없는 항목은 목록에서 제거
539
+ - 완전히 끝난 일만 completed로 표시하고, 막혔으면 in_progress 유지
540
+ - 요구사항이 바뀌면 계속 진행하기 전에 todo 목록부터 갱신
541
+
542
+ ## 필드 설명
543
+ - content: 명령형 작업 문구 (예: "테스트 실행")
544
+ - status: pending | in_progress | completed
545
+ - activeForm: (선택) 진행 중 표시 문구 (예: "테스트 실행 중")
546
+ - notes: (선택) 추가 맥락`,
547
+ parameters: TodoWriteParams,
548
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
549
+ const applied = applyTodoWrite(params.todos);
550
+ const summary = renderTodoWriteSummary(applied.state);
551
+ writeTodoWriteState(ctx, applied.state);
552
+ persistTodoWriteStateEntry(pi, applied.state);
553
+ await syncTodoOverlay(ctx, pi);
554
+ return {
555
+ content: [{ type: "text" as const, text: summary }],
556
+ details: { tasks: applied.state.tasks, summary },
557
+ };
558
+ },
559
+ renderResult(result, { expanded }, theme) {
560
+ if (!expanded) return new Text("", 0, 0);
561
+ const details = result.details as { summary?: unknown } | undefined;
562
+ const summary = typeof details?.summary === "string" ? details.summary : "";
563
+ return new Text(summary ? theme.fg("toolOutput", summary) : "", 0, 0);
564
+ },
565
+ });
566
+
567
+ pi.on("before_agent_start", async (_event, ctx) => {
568
+ const state = readTodoWriteState(ctx);
569
+ if (state.tasks.length === 0) return;
570
+
571
+ const key = getTodoStateKey(ctx);
572
+ const visibility = getTodoOverlayVisibility(state, todoOverlayMetaStore.get(key), getTodoTurn(key), Date.now());
573
+ if (visibility.hidden) {
574
+ clearTodoWriteState(ctx, pi);
575
+ todoOverlayMetaStore.delete(key);
576
+ hideTodoOverlay(key);
577
+ return;
578
+ }
579
+
580
+ const content = buildTodoTurnContext(state);
581
+ if (!content) return;
582
+ return {
583
+ message: {
584
+ customType: "todo-write-context",
585
+ content,
586
+ display: false,
587
+ details: { summary: renderTodoWriteSummary(state) },
588
+ },
589
+ };
590
+ });
591
+
592
+ pi.on("agent_start", async (_event, ctx) => {
593
+ setTodoOverlayAgentRunning(ctx, true);
594
+ await syncTodoOverlay(ctx, pi);
595
+ });
596
+
597
+ pi.on("agent_end", async (_event, ctx) => {
598
+ setTodoOverlayAgentRunning(ctx, false);
599
+ await syncTodoOverlay(ctx, pi);
600
+ });
601
+
602
+ pi.on("session_start", async (_event, ctx) => {
603
+ setTodoOverlayAgentRunning(ctx, false);
604
+ restoreTodoWriteState(ctx);
605
+ await syncTodoOverlay(ctx, pi);
606
+ });
607
+
608
+ pi.on("session_tree", async (_event, ctx) => {
609
+ setTodoOverlayAgentRunning(ctx, false);
610
+ restoreTodoWriteState(ctx);
611
+ await syncTodoOverlay(ctx, pi);
612
+ });
613
+
614
+ pi.on("session_compact", async (_event, ctx) => {
615
+ const state = restoreTodoWriteState(ctx);
616
+ await syncTodoOverlay(ctx, pi);
617
+ const reminder = buildPostCompactionTodoReminder(state);
618
+ if (!reminder) return;
619
+
620
+ if (ctx.hasUI) {
621
+ ctx.ui.notify("todo 알림: compaction 이후에도 남은 항목이 있습니다.", "info");
622
+ }
623
+
624
+ pi.sendMessage(
625
+ {
626
+ customType: TODO_COMPACTION_REMINDER_TYPE,
627
+ content: reminder,
628
+ display: true,
629
+ details: { summary: renderTodoWriteSummary(state) },
630
+ },
631
+ { deliverAs: "followUp", triggerTurn: true },
632
+ );
633
+ });
634
+
635
+ pi.on("message_end", async (_event, ctx) => {
636
+ incrementTodoTurn(ctx);
637
+ await syncTodoOverlay(ctx, pi);
638
+ });
639
+
640
+ pi.on("session_shutdown", async (_event, ctx) => {
641
+ const key = getTodoStateKey(ctx);
642
+ hideTodoOverlay(key);
643
+ todoOverlayMetaStore.delete(key);
644
+ todoOverlayAgentRunningStore.delete(key);
645
+ todoTurnStore.delete(key);
646
+ });
647
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@ryan_nookpi/pi-extension-todo-write-overlay",
3
+ "version": "0.1.0",
4
+ "description": "Top-right overlay todo_write tool extension for pi.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Jonghakseo/pi-extension.git",
9
+ "directory": "packages/todo-write-overlay"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Jonghakseo/pi-extension/issues"
13
+ },
14
+ "homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/todo-write-overlay#readme",
15
+ "type": "module",
16
+ "keywords": [
17
+ "pi-package"
18
+ ],
19
+ "files": [
20
+ "index.ts",
21
+ "README.md",
22
+ "assets/todo-overlay-chat.png",
23
+ "assets/todo-overlay-progress.png"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./index.ts"
28
+ ]
29
+ },
30
+ "peerDependencies": {
31
+ "@mariozechner/pi-ai": "*",
32
+ "@mariozechner/pi-coding-agent": "*",
33
+ "@mariozechner/pi-tui": "*",
34
+ "@sinclair/typebox": "*"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }