@leo-alvarenga/pi-todo-list 0.4.2 → 0.5.3

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 CHANGED
@@ -11,7 +11,7 @@ The agent manages tasks with a `todo` tool while you watch them live in a panel
11
11
  self-blocks, dependency cycles) are rejected before any state change.
12
12
  - **Live TUI panel** above the input editor — a header line with a
13
13
  collapse/expand chevron and per-status counters, Nerd Font status glyphs
14
- ( pending, 󱥸 in-progress,  completed), blocked-task hints, and a hardcoded
14
+ ( pending, 󱥸 in-progress,  completed), blocked-task hints, and a hardcoded
15
15
  8-row budget with a `… +N more` summary line. The panel hides itself
16
16
  entirely while the list is empty.
17
17
  - **Toggle with `Alt+T`** — collapsed by default; collapsed shows just the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo-alvarenga/pi-todo-list",
3
- "version": "0.4.2",
3
+ "version": "0.5.3",
4
4
  "description": "Session-aware todo list overlay for Pi: todo tool, /todos command, and a live TUI panel",
5
5
  "license": "MIT",
6
6
  "repository": "github.com/leo-alvarenga/pi-mono",
@@ -26,6 +26,9 @@
26
26
  "@earendil-works/pi-tui": "*",
27
27
  "typebox": "*"
28
28
  },
29
+ "dependencies": {
30
+ "@leo-alvarenga/pi-ext-core": "^0.3.2"
31
+ },
29
32
  "pi": {
30
33
  "extensions": [
31
34
  "./src/index.ts"
@@ -10,4 +10,4 @@ Track work with the todo tools unless the user explicitly says not to:
10
10
  - `todo add` — record each item the user wants tracked (text or texts).
11
11
  - `todo update` — set `status: completed` as items finish; update status on every progress change (in-progress, blocked, …).
12
12
  - `todo remove` — drop tasks that are no longer relevant.
13
- - "complete everything" / "done with all" → `todo_complete_all`, not a loop of `todo update`.
13
+ - "complete everything" / "done with all" → `todo_complete_all`, not a loop of `todo update`.
package/src/command.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
 
4
- import { groupByStatus } from "./core";
4
+ import { applyAction } from "./core";
5
+ import { groupByStatus } from "./query";
5
6
  import { REPORT_ENTRY } from "./constants";
6
7
  import type { TodoStore } from "./state";
7
8
  import type { Todo } from "./types";
@@ -42,6 +43,25 @@ export function registerTodosCommand(pi: ExtensionAPI, store: TodoStore): void {
42
43
  },
43
44
  });
44
45
 
46
+ pi.registerCommand("todos-clear", {
47
+ description: "Manually clears all todos",
48
+ handler: async (_args, ctx) => {
49
+ const state = store.getState(ctx);
50
+ if (!state.todos.length) return;
51
+
52
+ const result = applyAction(state, {
53
+ action: "clear",
54
+ });
55
+
56
+ if (!result.ok) {
57
+ ctx.ui.notify("Could not clear todos", "error");
58
+ return;
59
+ }
60
+
61
+ store.commit(ctx, result.state);
62
+ },
63
+ });
64
+
45
65
  pi.registerEntryRenderer<{ todos: Todo[] }>(
46
66
  REPORT_ENTRY,
47
67
  (entry, _options, theme) => {
package/src/core.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { MAX_TEXT_LENGTH } from "./constants";
2
2
  import type { Todo, TodoAction, TodoState, TodoStatus } from "./types";
3
+ import { wouldCreateCycle } from "./query";
3
4
 
4
5
  export type Patch = {
5
6
  text?: string;
@@ -129,37 +130,6 @@ function remove(state: TodoState, id: number): ActionResult {
129
130
  };
130
131
  }
131
132
 
132
- const blockedSuffix = (t: Todo, todos: Todo[]): string => {
133
- const waiting = t.blockedBy.filter((b) => {
134
- const bt = todos.find((x) => x.id === b);
135
-
136
- return !bt || bt.status !== "completed";
137
- });
138
-
139
- if (waiting.length > 0) {
140
- return ` (blocked by ${waiting.map((b) => `#${b}`).join(", ")})`;
141
- }
142
-
143
- return "";
144
- };
145
-
146
- function list(state: TodoState): ActionResult {
147
- if (state.todos.length === 0) {
148
- return { ok: true, state, text: "No todos" };
149
- }
150
-
151
- return {
152
- state,
153
- ok: true,
154
- text: state.todos
155
- .map(
156
- (t) =>
157
- `[${t.status === "completed" ? "x" : " "}] #${t.id}: ${t.text}${blockedSuffix(t, state.todos)}`,
158
- )
159
- .join("\n"),
160
- };
161
- }
162
-
163
133
  function clear(): ActionResult {
164
134
  return {
165
135
  ok: true,
@@ -170,7 +140,8 @@ function clear(): ActionResult {
170
140
 
171
141
  function addMany(state: TodoState, texts: string[]): ActionResult {
172
142
  const cleaned = texts.map(cleanText);
173
- if (cleaned.some((t) => !t)) return { ok: false, error: "text required for add" };
143
+ if (cleaned.some((t) => !t))
144
+ return { ok: false, error: "text required for add" };
174
145
  if (cleaned.some((t) => t && t.length > MAX_TEXT_LENGTH)) return textError();
175
146
 
176
147
  let nextId = state.nextId;
@@ -206,10 +177,6 @@ function removeMany(state: TodoState, ids: number[]): ActionResult {
206
177
  };
207
178
  }
208
179
 
209
- /**
210
- * Apply a tool action against a copy of the state.
211
- * Returns the new state only when validation passes; the caller commits it.
212
- */
213
180
  export function applyAction(
214
181
  state: TodoState,
215
182
  params: {
@@ -224,7 +191,8 @@ export function applyAction(
224
191
  ): ActionResult {
225
192
  switch (params.action) {
226
193
  case "add":
227
- if (params.texts !== undefined && params.texts.length > 0) return addMany(state, params.texts);
194
+ if (params.texts !== undefined && params.texts.length > 0)
195
+ return addMany(state, params.texts);
228
196
  return add(state, params.text ?? "");
229
197
 
230
198
  case "update":
@@ -235,7 +203,8 @@ export function applyAction(
235
203
  return update(state, params.id, params);
236
204
 
237
205
  case "remove":
238
- if (params.ids !== undefined && params.ids.length > 0) return removeMany(state, params.ids);
206
+ if (params.ids !== undefined && params.ids.length > 0)
207
+ return removeMany(state, params.ids);
239
208
  if (params.id === undefined) {
240
209
  return { ok: false, error: "id required for remove" };
241
210
  }
@@ -243,57 +212,24 @@ export function applyAction(
243
212
  return remove(state, params.id);
244
213
 
245
214
  case "list":
246
- return list(state);
215
+ return { ...list(state), ok: true, state };
247
216
 
248
217
  case "clear":
249
- return clear();
218
+ return clear() as any;
250
219
  }
251
220
  }
252
221
 
253
- /**
254
- * True if `changedId` can reach itself by following blockedBy edges
255
- * (direct, or through a chain) — i.e. the update would create a cycle.
256
- */
257
- export function wouldCreateCycle(todos: Todo[], changedId: number): boolean {
258
- const adj = new Map<number, number[]>();
259
- for (const t of todos) adj.set(t.id, [...t.blockedBy]);
260
-
261
- const done = new Set<number>();
262
- const visiting = new Set<number>();
263
-
264
- const dfs = (id: number): boolean => {
265
- if (visiting.has(id)) return true;
266
- if (done.has(id)) return false;
267
-
268
- visiting.add(id);
269
-
270
- for (const dep of adj.get(id) ?? []) {
271
- if (dfs(dep)) return true;
272
- }
273
-
274
- visiting.delete(id);
275
- done.add(id);
276
-
277
- return false;
278
- };
279
-
280
- return dfs(changedId);
281
- }
282
-
283
- export function groupByStatus(todos: Todo[]): {
284
- completed: Todo[];
285
- inProgress: Todo[];
286
- pending: Todo[];
287
- } {
288
- const completed: Todo[] = [];
289
- const inProgress: Todo[] = [];
290
- const pending: Todo[] = [];
291
-
292
- for (const t of todos) {
293
- if (t.status === "completed") completed.push(t);
294
- else if (t.status === "in-progress") inProgress.push(t);
295
- else pending.push(t);
222
+ function list(state: TodoState): { text: string } {
223
+ if (state.todos.length === 0) {
224
+ return { text: "No todos" };
296
225
  }
297
226
 
298
- return { completed, inProgress, pending };
227
+ return {
228
+ text: state.todos
229
+ .map(
230
+ (t) =>
231
+ `[${t.status === "completed" ? "x" : " "}] #${t.id}: ${t.text}`,
232
+ )
233
+ .join("\n"),
234
+ };
299
235
  }
package/src/index.ts CHANGED
@@ -4,23 +4,30 @@ import { registerTodosCommand } from "./command";
4
4
  import { STATE_ENTRY, WIDGET_KEY } from "./constants";
5
5
  import { TodoStore } from "./state";
6
6
  import { registerTodoTool, registerTodoCompleteAllTool } from "./tool";
7
- import { registerTodoWidget, refreshWidget } from "./widget";
7
+ import { registerTodoWidget } from "./widget";
8
8
 
9
9
  export default function (pi: ExtensionAPI): void {
10
10
  const store = new TodoStore(
11
11
  (snapshot) => pi.appendEntry(STATE_ENTRY, snapshot),
12
- (ctx) => refreshWidget(ctx, store),
12
+ undefined,
13
13
  );
14
14
 
15
+ const panelControls = registerTodoWidget(pi, store);
16
+
15
17
  registerTodoTool(pi, store);
16
18
  registerTodoCompleteAllTool(pi, store);
17
19
  registerTodosCommand(pi, store);
18
- registerTodoWidget(pi, store);
19
20
 
20
- pi.on("session_start", (_event, ctx) => store.replay(ctx));
21
- pi.on("session_tree", (_event, ctx) => store.replay(ctx));
21
+ pi.on("session_start", (_event, ctx) => {
22
+ store.replay(ctx);
23
+ panelControls.refresh(ctx);
24
+ });
25
+ pi.on("session_tree", (_event, ctx) => {
26
+ store.replay(ctx);
27
+ panelControls.refresh(ctx);
28
+ });
22
29
  pi.on("session_before_compact", (_event, ctx) => store.persistSnapshot(ctx));
23
30
  pi.on("session_shutdown", (_event, ctx) => {
24
31
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
25
32
  });
26
- }
33
+ }
package/src/query.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { Todo, TodoState } from "./types";
2
+
3
+ const blockedSuffix = (t: Todo, todos: Todo[]): string => {
4
+ const waiting = t.blockedBy.filter((b) => {
5
+ const bt = todos.find((x) => x.id === b);
6
+ return !bt || bt.status !== "completed";
7
+ });
8
+
9
+ if (waiting.length > 0) {
10
+ return ` (blocked by ${waiting.map((b) => `#${b}`).join(", ")})`;
11
+ }
12
+
13
+ return "";
14
+ };
15
+
16
+ export function list(state: TodoState): { text: string } {
17
+ if (state.todos.length === 0) {
18
+ return { text: "No todos" };
19
+ }
20
+
21
+ return {
22
+ text: state.todos
23
+ .map(
24
+ (t) =>
25
+ `[${t.status === "completed" ? "x" : " "}] #${t.id}: ${t.text}${blockedSuffix(t, state.todos)}`,
26
+ )
27
+ .join("\n"),
28
+ };
29
+ }
30
+
31
+ export function wouldCreateCycle(todos: Todo[], changedId: number): boolean {
32
+ const adj = new Map<number, number[]>();
33
+ for (const t of todos) adj.set(t.id, [...t.blockedBy]);
34
+
35
+ const done = new Set<number>();
36
+ const visiting = new Set<number>();
37
+
38
+ const dfs = (id: number): boolean => {
39
+ if (visiting.has(id)) return true;
40
+ if (done.has(id)) return false;
41
+
42
+ visiting.add(id);
43
+
44
+ for (const dep of adj.get(id) ?? []) {
45
+ if (dfs(dep)) return true;
46
+ }
47
+
48
+ visiting.delete(id);
49
+ done.add(id);
50
+
51
+ return false;
52
+ };
53
+
54
+ return dfs(changedId);
55
+ }
56
+
57
+ export function groupByStatus(todos: Todo[]): {
58
+ completed: Todo[];
59
+ inProgress: Todo[];
60
+ pending: Todo[];
61
+ } {
62
+ const completed: Todo[] = [];
63
+ const inProgress: Todo[] = [];
64
+ const pending: Todo[] = [];
65
+
66
+ for (const t of todos) {
67
+ if (t.status === "completed") completed.push(t);
68
+ else if (t.status === "in-progress") inProgress.push(t);
69
+ else pending.push(t);
70
+ }
71
+
72
+ return { completed, inProgress, pending };
73
+ }
package/src/state.ts CHANGED
@@ -1,82 +1,45 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import { createSessionStore, SessionRecordStore } from "@leo-alvarenga/pi-ext-core";
4
+
3
5
  import { STATE_ENTRY } from "./constants";
4
6
  import type { TodoDetails, TodoState } from "./types";
5
7
 
6
- /**
7
- * Session-scoped todo state.
8
- *
9
- * State is keyed by the active session id so forked/parallel sessions never
10
- * overwrite each other, and it is replayed from the session branch on every
11
- * session event, so it survives /reload and compaction without the extension
12
- * writing any of its own files.
13
- */
14
8
  export class TodoStore {
15
- private readonly stateBySession = new Map<string, TodoState>();
9
+ private store: SessionRecordStore<TodoState>;
16
10
 
17
11
  constructor(
18
12
  private readonly persist: (snapshot: TodoState) => void,
19
- private readonly emitChange: (ctx: ExtensionContext) => void,
20
- ) {}
21
-
22
- private sid(ctx: ExtensionContext): string {
23
- return ctx.sessionManager.getSessionId();
13
+ private readonly onRefresh?: (ctx: ExtensionContext) => void,
14
+ ) {
15
+ this.store = createSessionStore<TodoState>({
16
+ empty: () => ({ todos: [], nextId: 1 }),
17
+ entryType: STATE_ENTRY,
18
+ snapshotOf: (entry) => {
19
+ if (entry.type === "custom" && entry.customType === STATE_ENTRY) {
20
+ return entry.data as TodoState | undefined;
21
+ }
22
+ },
23
+ onChange: (state, ctx) => {
24
+ this.persist(state);
25
+ this.onRefresh?.(ctx);
26
+ },
27
+ });
24
28
  }
25
29
 
26
30
  getState(ctx: ExtensionContext): TodoState {
27
- const sid = this.sid(ctx);
28
-
29
- let s = this.stateBySession.get(sid);
30
- if (!s) this.stateBySession.set(sid, (s = { todos: [], nextId: 1 }));
31
-
32
- return s;
31
+ return this.store.getState(ctx);
33
32
  }
34
33
 
35
- /** Commit a validated snapshot: update memory, persist, refresh the panel. */
36
- commit(ctx: ExtensionContext, next: TodoState): void {
37
- this.stateBySession.set(this.sid(ctx), next);
38
- this.persist(next);
39
- this.emitChange(ctx);
34
+ commit(ctx: ExtensionContext, state: TodoState): void {
35
+ this.store.commit(ctx, state);
40
36
  }
41
37
 
42
- /** Rebuild the current session's state from the session branch (no disk writes). */
43
38
  replay(ctx: ExtensionContext): void {
44
- const s = this.getState(ctx);
45
- s.todos = [];
46
- s.nextId = 1;
47
-
48
- for (const entry of ctx.sessionManager.getBranch()) {
49
- if (entry.type === "custom" && entry.customType === STATE_ENTRY) {
50
- const d = entry.data as TodoState | undefined;
51
-
52
- if (d) {
53
- s.todos = d.todos;
54
- s.nextId = d.nextId;
55
- }
56
-
57
- continue;
58
- }
59
-
60
- if (
61
- entry.type === "message" &&
62
- entry.message.role === "toolResult" &&
63
- entry.message.toolName === "todo"
64
- ) {
65
- const d = entry.message.details as TodoDetails | undefined;
66
-
67
- if (d && !d.error) {
68
- s.todos = d.todos;
69
- s.nextId = d.nextId;
70
- }
71
- }
72
- }
73
-
74
- this.emitChange(ctx);
39
+ this.store.replay(ctx);
75
40
  }
76
41
 
77
- /** Persist the latest snapshot right before compaction so it lands after the cut point. */
78
42
  persistSnapshot(ctx: ExtensionContext): void {
79
- const s = this.getState(ctx);
80
- if (s.todos.length > 0) this.persist(s);
43
+ this.store.persistSnapshot(ctx);
81
44
  }
82
45
  }
package/src/tool.ts CHANGED
@@ -10,12 +10,32 @@ import type { TodoDetails, TodoState } from "./types";
10
10
 
11
11
  const TodoParams = Type.Object({
12
12
  action: StringEnum(["add", "update", "remove", "list", "clear"] as const),
13
- text: Type.Optional(Type.String({ description: "Task text (required for add; optional for update)" })),
14
- texts: Type.Optional(Type.Array(Type.String(), { description: "Task texts; add all at once (optional; used with action=add)" })),
15
- id: Type.Optional(Type.Number({ description: "Task id (required for remove; alternative to ids)" })),
16
- ids: Type.Optional(Type.Array(Type.Number(), { description: "Task ids; remove all at once (optional; used with action=remove)" })),
13
+ text: Type.Optional(
14
+ Type.String({
15
+ description: "Task text (required for add; optional for update)",
16
+ }),
17
+ ),
18
+ texts: Type.Optional(
19
+ Type.Array(Type.String(), {
20
+ description:
21
+ "Task texts; add all at once (optional; used with action=add)",
22
+ }),
23
+ ),
24
+ id: Type.Optional(
25
+ Type.Number({
26
+ description: "Task id (required for remove; alternative to ids)",
27
+ }),
28
+ ),
29
+ ids: Type.Optional(
30
+ Type.Array(Type.Number(), {
31
+ description:
32
+ "Task ids; remove all at once (optional; used with action=remove)",
33
+ }),
34
+ ),
17
35
  status: Type.Optional(StringEnum([...STATUSES] as const)),
18
- blockedBy: Type.Optional(Type.Array(Type.Number(), { description: "Task ids this task depends on" })),
36
+ blockedBy: Type.Optional(
37
+ Type.Array(Type.Number(), { description: "Task ids this task depends on" }),
38
+ ),
19
39
  });
20
40
 
21
41
  export function registerTodoTool(pi: ExtensionAPI, store: TodoStore): void {
@@ -25,7 +45,8 @@ export function registerTodoTool(pi: ExtensionAPI, store: TodoStore): void {
25
45
  parameters: TodoParams,
26
46
  description:
27
47
  "Manage the session todo list. Actions: add (text or texts), update (id, optional status/text/blockedBy), remove (id or ids), list, clear",
28
- promptSnippet: "Manage the session todo list: add/update/remove/list/clear tasks",
48
+ promptSnippet:
49
+ "Manage the session todo list: add/update/remove/list/clear tasks",
29
50
  promptGuidelines: [
30
51
  "Always track the work using the todo tools.",
31
52
  "Record each item the user wants tracked with todo add.",
@@ -33,7 +54,7 @@ export function registerTodoTool(pi: ExtensionAPI, store: TodoStore): void {
33
54
  "When the user says 'complete everything' / 'done with all', use todo_complete_all instead of looping todo update.",
34
55
  ],
35
56
 
36
- async execute(toolCallId, params, _signal, _onUpdate, ctx) {
57
+ async execute(_, params, _signal, _onUpdate, ctx) {
37
58
  const current = store.getState(ctx);
38
59
  const result = applyAction(current, params);
39
60
 
@@ -52,29 +73,41 @@ export function registerTodoTool(pi: ExtensionAPI, store: TodoStore): void {
52
73
  store.commit(ctx, result.state);
53
74
  return {
54
75
  content: [{ type: "text", text: result.text }],
55
- details: { action: params.action, todos: [...result.state.todos], nextId: result.state.nextId } satisfies TodoDetails,
76
+ details: {
77
+ action: params.action,
78
+ todos: [...result.state.todos],
79
+ nextId: result.state.nextId,
80
+ } satisfies TodoDetails,
56
81
  };
57
82
  },
58
83
 
59
84
  renderCall(args, theme, _context) {
60
- let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
85
+ let text =
86
+ theme.fg("toolTitle", theme.bold("todo ")) +
87
+ theme.fg("muted", args.action);
61
88
  if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
62
- if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
89
+ if (args.id !== undefined)
90
+ text += ` ${theme.fg("accent", `#${args.id}`)}`;
63
91
  return new Text(text, 0, 0);
64
92
  },
65
93
 
66
94
  renderResult(result, _options, theme, _context) {
67
95
  const details = result.details as TodoDetails | undefined;
68
- if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
96
+ if (details?.error)
97
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
69
98
  const text = result.content[0];
70
99
  const msg = text?.type === "text" ? text.text : "";
71
- if (details?.action === "list") return new Text(theme.fg("muted", msg), 0, 0);
100
+ if (details?.action === "list")
101
+ return new Text(theme.fg("muted", msg), 0, 0);
72
102
  return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
73
103
  },
74
104
  });
75
105
  }
76
106
 
77
- export function registerTodoCompleteAllTool(pi: ExtensionAPI, store: TodoStore): void {
107
+ export function registerTodoCompleteAllTool(
108
+ pi: ExtensionAPI,
109
+ store: TodoStore,
110
+ ): void {
78
111
  pi.registerTool({
79
112
  name: "todo_complete_all",
80
113
  label: "Complete All Todos",
@@ -86,26 +119,40 @@ export function registerTodoCompleteAllTool(pi: ExtensionAPI, store: TodoStore):
86
119
 
87
120
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
88
121
  const current = store.getState(ctx);
122
+
89
123
  const n = current.todos.length;
90
124
  const cleared: TodoState = { todos: [], nextId: 1 };
125
+
91
126
  store.commit(ctx, cleared);
127
+
92
128
  return {
93
- content: [{ type: "text", text: `All ${n} todos completed and cleared` }],
94
- details: { action: "clear", todos: [], nextId: 1 } satisfies TodoDetails,
129
+ content: [
130
+ { type: "text", text: `All ${n} todos completed and cleared` },
131
+ ],
132
+ details: {
133
+ action: "clear",
134
+ todos: [],
135
+ nextId: 1,
136
+ } satisfies TodoDetails,
95
137
  };
96
138
  },
97
139
 
98
140
  renderCall(_args, theme) {
99
- return new Text(theme.fg("toolTitle", theme.bold("todo_complete_all")), 0, 0);
141
+ return new Text(
142
+ theme.fg("toolTitle", theme.bold("todo_complete_all")),
143
+ 0,
144
+ 0,
145
+ );
100
146
  },
101
147
 
102
148
  renderResult(result, _options, theme) {
103
149
  const text = result.content[0];
104
150
  return new Text(
105
- theme.fg("success", "✓ ") + theme.fg("muted", text?.type === "text" ? text.text : ""),
151
+ theme.fg("success", "✓ ") +
152
+ theme.fg("muted", text?.type === "text" ? text.text : ""),
106
153
  0,
107
154
  0,
108
155
  );
109
156
  },
110
157
  });
111
- }
158
+ }
package/src/widget.ts CHANGED
@@ -1,76 +1,28 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- Theme,
5
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { createPanelWidget, PanelWidgetSpec } from "@leo-alvarenga/pi-ext-core";
6
4
 
7
5
  import { PANEL_TOGGLE_CHORD, WIDGET_KEY } from "./constants";
8
6
  import type { TodoStore } from "./state";
9
7
  import type { TodoState } from "./types";
10
8
  import { getStyledTodoList } from "./utils";
11
9
 
12
- let collapsed = true;
13
- const hideIfEmpty = true;
14
-
15
- class TodoWidget {
16
- private cachedWidth: number | undefined;
17
- private cachedLines: string[] | undefined;
18
-
19
- constructor(
20
- private readonly theme: Theme,
21
- private readonly snapshot: () => TodoState,
22
- private readonly isCollapsed: () => boolean,
23
- ) {}
24
-
25
- render(width: number): string[] {
26
- if (hideIfEmpty && this.snapshot().todos.length === 0) {
27
- return [];
28
- }
29
-
30
- if (this.cachedLines !== undefined && this.cachedWidth === width) {
31
- return this.cachedLines;
32
- }
33
-
34
- const lines = getStyledTodoList(
35
- this.snapshot().todos,
36
- this.theme,
37
- width,
38
- this.isCollapsed(),
39
- );
40
-
41
- this.cachedWidth = width;
42
- this.cachedLines = lines;
43
-
44
- return lines;
45
- }
46
-
47
- invalidate(): void {
48
- this.cachedWidth = undefined;
49
- this.cachedLines = undefined;
50
- }
10
+ export function registerTodoWidget(pi: ExtensionAPI, store: TodoStore) {
11
+ const spec: PanelWidgetSpec<TodoState> = {
12
+ widgetKey: WIDGET_KEY,
13
+ maxRows: 20,
14
+ emptyText: "No todos",
15
+ toggleChord: PANEL_TOGGLE_CHORD,
16
+ isEmpty: (s) => s.todos.length === 0,
17
+ store,
18
+ moreLabel: (n) => `…and ${n} more`,
19
+ header: (s, theme, isCollapsed) =>
20
+ theme.fg("muted", isCollapsed ? "▶ Todos" : "▼ Todos"),
21
+ rows: (s, theme) => getStyledTodoList(s.todos, theme, 80, false),
22
+ };
23
+
24
+ const controls = createPanelWidget(pi, spec);
25
+ controls.register();
26
+ return controls;
51
27
  }
52
28
 
53
- export function refreshWidget(ctx: ExtensionContext, store: TodoStore): void {
54
- if (!ctx.hasUI) return;
55
-
56
- ctx.ui.setWidget(
57
- WIDGET_KEY,
58
- (_tui, theme) =>
59
- new TodoWidget(
60
- theme,
61
- () => store.getState(ctx),
62
- () => collapsed,
63
- ),
64
- );
65
- }
66
-
67
- export function registerTodoWidget(pi: ExtensionAPI, store: TodoStore): void {
68
- pi.registerShortcut(PANEL_TOGGLE_CHORD, {
69
- description: "Toggle todos panel",
70
- handler: (ctx) => {
71
- collapsed = !collapsed;
72
-
73
- refreshWidget(ctx, store);
74
- },
75
- });
76
- }