@leo-alvarenga/pi-todo-list 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo-alvarenga/pi-todo-list",
3
- "version": "0.5.0",
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"
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 { applyAction, 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";
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,
@@ -207,10 +177,6 @@ function removeMany(state: TodoState, ids: number[]): ActionResult {
207
177
  };
208
178
  }
209
179
 
210
- /**
211
- * Apply a tool action against a copy of the state.
212
- * Returns the new state only when validation passes; the caller commits it.
213
- */
214
180
  export function applyAction(
215
181
  state: TodoState,
216
182
  params: {
@@ -246,57 +212,24 @@ export function applyAction(
246
212
  return remove(state, params.id);
247
213
 
248
214
  case "list":
249
- return list(state);
215
+ return { ...list(state), ok: true, state };
250
216
 
251
217
  case "clear":
252
- return clear();
218
+ return clear() as any;
253
219
  }
254
220
  }
255
221
 
256
- /**
257
- * True if `changedId` can reach itself by following blockedBy edges
258
- * (direct, or through a chain) — i.e. the update would create a cycle.
259
- */
260
- export function wouldCreateCycle(todos: Todo[], changedId: number): boolean {
261
- const adj = new Map<number, number[]>();
262
- for (const t of todos) adj.set(t.id, [...t.blockedBy]);
263
-
264
- const done = new Set<number>();
265
- const visiting = new Set<number>();
266
-
267
- const dfs = (id: number): boolean => {
268
- if (visiting.has(id)) return true;
269
- if (done.has(id)) return false;
270
-
271
- visiting.add(id);
272
-
273
- for (const dep of adj.get(id) ?? []) {
274
- if (dfs(dep)) return true;
275
- }
276
-
277
- visiting.delete(id);
278
- done.add(id);
279
-
280
- return false;
281
- };
282
-
283
- return dfs(changedId);
284
- }
285
-
286
- export function groupByStatus(todos: Todo[]): {
287
- completed: Todo[];
288
- inProgress: Todo[];
289
- pending: Todo[];
290
- } {
291
- const completed: Todo[] = [];
292
- const inProgress: Todo[] = [];
293
- const pending: Todo[] = [];
294
-
295
- for (const t of todos) {
296
- if (t.status === "completed") completed.push(t);
297
- else if (t.status === "in-progress") inProgress.push(t);
298
- else pending.push(t);
222
+ function list(state: TodoState): { text: string } {
223
+ if (state.todos.length === 0) {
224
+ return { text: "No todos" };
299
225
  }
300
226
 
301
- 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
+ };
302
235
  }
package/src/index.ts CHANGED
@@ -4,21 +4,28 @@ 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);
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/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
- }