@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 +4 -1
- package/src/command.ts +2 -1
- package/src/core.ts +14 -81
- package/src/index.ts +12 -5
- package/src/query.ts +73 -0
- package/src/state.ts +23 -60
- package/src/widget.ts +20 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leo-alvarenga/pi-todo-list",
|
|
3
|
-
"version": "0.5.
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
21
|
-
|
|
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
|
|
9
|
+
private store: SessionRecordStore<TodoState>;
|
|
16
10
|
|
|
17
11
|
constructor(
|
|
18
12
|
private readonly persist: (snapshot: TodoState) => void,
|
|
19
|
-
private readonly
|
|
20
|
-
) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
}
|