@leo-alvarenga/pi-todo-list 0.5.0 → 0.6.1
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 +10 -6
- package/src/command.ts +8 -4
- package/src/constants.ts +4 -7
- package/src/core.ts +14 -81
- package/src/index.ts +14 -8
- package/src/query.ts +73 -0
- package/src/state.ts +21 -78
- package/src/tool.ts +6 -3
- package/src/types.ts +0 -9
- package/src/utils.ts +41 -67
- package/src/widget.ts +34 -74
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leo-alvarenga/pi-todo-list",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
|
+
"!src/test",
|
|
18
19
|
"skills",
|
|
19
20
|
"README.md",
|
|
20
|
-
"LICENSE"
|
|
21
|
-
"!src/*.test.ts"
|
|
21
|
+
"LICENSE"
|
|
22
22
|
],
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"@earendil-works/pi-ai": "*",
|
|
@@ -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.4"
|
|
31
|
+
},
|
|
29
32
|
"pi": {
|
|
30
33
|
"extensions": [
|
|
31
34
|
"./src/index.ts"
|
|
@@ -33,11 +36,12 @@
|
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
38
|
"@types/node": "^22.0.0",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
39
|
+
"typescript": "^7.0.2",
|
|
40
|
+
"vitest": "^3.2.4"
|
|
38
41
|
},
|
|
39
42
|
"scripts": {
|
|
40
43
|
"build": "tsc --noEmit",
|
|
41
|
-
"typecheck": "tsc --noEmit"
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "vitest run"
|
|
42
46
|
}
|
|
43
47
|
}
|
package/src/command.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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
|
-
import type {
|
|
7
|
-
import type { Todo } from "./types";
|
|
7
|
+
import type { SessionRecordStore } from "@leo-alvarenga/pi-ext-core";
|
|
8
|
+
import type { Todo, TodoState } from "./types";
|
|
8
9
|
import { getStyledTodo } from "./utils";
|
|
9
10
|
|
|
10
11
|
const renderList = (todos: Todo[], theme: Theme): string => {
|
|
@@ -29,7 +30,10 @@ const renderList = (todos: Todo[], theme: Theme): string => {
|
|
|
29
30
|
return lines.join("\n");
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
export function registerTodosCommand(
|
|
33
|
+
export function registerTodosCommand(
|
|
34
|
+
pi: ExtensionAPI,
|
|
35
|
+
store: SessionRecordStore<TodoState>,
|
|
36
|
+
): void {
|
|
33
37
|
pi.registerCommand("todos", {
|
|
34
38
|
description: "Show all todos grouped by status",
|
|
35
39
|
handler: async (_args, ctx) => {
|
package/src/constants.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { KeyId } from "@earendil-works/pi-tui";
|
|
2
|
-
import {
|
|
2
|
+
import type { StatusUi } from "@leo-alvarenga/pi-ext-core";
|
|
3
|
+
|
|
4
|
+
import type { TodoStatus } from "./types";
|
|
3
5
|
|
|
4
6
|
/** Task statuses, in lifecycle order. All limits are hardcoded — no config files. */
|
|
5
7
|
export const STATUSES = ["pending", "in-progress", "completed"] as const;
|
|
6
8
|
|
|
7
|
-
export const STATUS_STYLES: Record<TodoStatus,
|
|
9
|
+
export const STATUS_STYLES: Record<TodoStatus, StatusUi> = {
|
|
8
10
|
pending: { icon: " ", fg: "text", color: "text" },
|
|
9
11
|
"in-progress": { icon: " ", fg: "accent", color: "accent", bold: true },
|
|
10
12
|
completed: { icon: " ", fg: "dim", color: "success", strikethrough: true },
|
|
@@ -27,8 +29,3 @@ export const REPORT_ENTRY = "todos.report";
|
|
|
27
29
|
|
|
28
30
|
/** Chord that toggles the panel. Chosen in setup: free in pi's default keybindings. */
|
|
29
31
|
export const PANEL_TOGGLE_CHORD: KeyId = "alt+t";
|
|
30
|
-
|
|
31
|
-
export const PANEL_STATE_ICON = {
|
|
32
|
-
collapsed: "",
|
|
33
|
-
expanded: "",
|
|
34
|
-
};
|
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
|
@@ -2,23 +2,29 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
import { registerTodosCommand } from "./command";
|
|
4
4
|
import { STATE_ENTRY, WIDGET_KEY } from "./constants";
|
|
5
|
-
import {
|
|
5
|
+
import { createTodoStore } 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
|
-
const store =
|
|
11
|
-
|
|
12
|
-
(ctx) => refreshWidget(ctx, store),
|
|
10
|
+
const store = createTodoStore((snapshot) =>
|
|
11
|
+
pi.appendEntry(STATE_ENTRY, snapshot),
|
|
13
12
|
);
|
|
14
13
|
|
|
14
|
+
const panelControls = registerTodoWidget(pi, store);
|
|
15
|
+
|
|
15
16
|
registerTodoTool(pi, store);
|
|
16
17
|
registerTodoCompleteAllTool(pi, store);
|
|
17
18
|
registerTodosCommand(pi, store);
|
|
18
|
-
registerTodoWidget(pi, store);
|
|
19
19
|
|
|
20
|
-
pi.on("session_start", (_event, ctx) =>
|
|
21
|
-
|
|
20
|
+
pi.on("session_start", (_event, ctx) => {
|
|
21
|
+
store.replay(ctx);
|
|
22
|
+
panelControls.refresh(ctx);
|
|
23
|
+
});
|
|
24
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
25
|
+
store.replay(ctx);
|
|
26
|
+
panelControls.refresh(ctx);
|
|
27
|
+
});
|
|
22
28
|
pi.on("session_before_compact", (_event, ctx) => store.persistSnapshot(ctx));
|
|
23
29
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
24
30
|
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,25 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
export class TodoStore {
|
|
15
|
-
private readonly stateBySession = new Map<string, TodoState>();
|
|
16
|
-
|
|
17
|
-
constructor(
|
|
18
|
-
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();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
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;
|
|
33
|
-
}
|
|
34
|
-
|
|
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);
|
|
40
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
createSessionStore,
|
|
5
|
+
type SessionRecordStore,
|
|
6
|
+
} from "@leo-alvarenga/pi-ext-core";
|
|
41
7
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Persist the latest snapshot right before compaction so it lands after the cut point. */
|
|
78
|
-
persistSnapshot(ctx: ExtensionContext): void {
|
|
79
|
-
const s = this.getState(ctx);
|
|
80
|
-
if (s.todos.length > 0) this.persist(s);
|
|
81
|
-
}
|
|
8
|
+
import { STATE_ENTRY } from "./constants";
|
|
9
|
+
import type { TodoState } from "./types";
|
|
10
|
+
|
|
11
|
+
/** Session-scoped todo state, committed to the `todos.state` custom entry. */
|
|
12
|
+
export function createTodoStore(
|
|
13
|
+
persist: (snapshot: TodoState) => void,
|
|
14
|
+
onRefresh?: (ctx: ExtensionContext) => void,
|
|
15
|
+
): SessionRecordStore<TodoState> {
|
|
16
|
+
return createSessionStore<TodoState>({
|
|
17
|
+
empty: () => ({ todos: [], nextId: 1 }),
|
|
18
|
+
entryType: STATE_ENTRY,
|
|
19
|
+
snapshotOf: (entry) => entry.data as TodoState | undefined,
|
|
20
|
+
onChange: (state, ctx) => {
|
|
21
|
+
persist(state);
|
|
22
|
+
onRefresh?.(ctx);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
82
25
|
}
|
package/src/tool.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Type } from "typebox";
|
|
|
5
5
|
|
|
6
6
|
import { STATUSES } from "./constants";
|
|
7
7
|
import { applyAction } from "./core";
|
|
8
|
-
import type {
|
|
8
|
+
import type { SessionRecordStore } from "@leo-alvarenga/pi-ext-core";
|
|
9
9
|
import type { TodoDetails, TodoState } from "./types";
|
|
10
10
|
|
|
11
11
|
const TodoParams = Type.Object({
|
|
@@ -38,7 +38,10 @@ const TodoParams = Type.Object({
|
|
|
38
38
|
),
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
export function registerTodoTool(
|
|
41
|
+
export function registerTodoTool(
|
|
42
|
+
pi: ExtensionAPI,
|
|
43
|
+
store: SessionRecordStore<TodoState>,
|
|
44
|
+
): void {
|
|
42
45
|
pi.registerTool({
|
|
43
46
|
name: "todo",
|
|
44
47
|
label: "Todo",
|
|
@@ -106,7 +109,7 @@ export function registerTodoTool(pi: ExtensionAPI, store: TodoStore): void {
|
|
|
106
109
|
|
|
107
110
|
export function registerTodoCompleteAllTool(
|
|
108
111
|
pi: ExtensionAPI,
|
|
109
|
-
store:
|
|
112
|
+
store: SessionRecordStore<TodoState>,
|
|
110
113
|
): void {
|
|
111
114
|
pi.registerTool({
|
|
112
115
|
name: "todo_complete_all",
|
package/src/types.ts
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
import { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { STATUSES } from "./constants";
|
|
3
2
|
|
|
4
3
|
export type TodoStatus = (typeof STATUSES)[number];
|
|
5
4
|
|
|
6
|
-
export type TodoStatusUi = {
|
|
7
|
-
icon: string;
|
|
8
|
-
fg: ThemeColor;
|
|
9
|
-
bold?: boolean;
|
|
10
|
-
color: ThemeColor;
|
|
11
|
-
strikethrough?: boolean;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
5
|
export interface Todo {
|
|
15
6
|
id: number;
|
|
16
7
|
text: string;
|
package/src/utils.ts
CHANGED
|
@@ -1,63 +1,14 @@
|
|
|
1
1
|
import { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { PANEL_STATE_ICON } from "@leo-alvarenga/pi-ext-core";
|
|
3
3
|
|
|
4
|
-
import { Todo, TodoStatus } from "./types";
|
|
5
|
-
import { MAX_PANEL_ROWS,
|
|
4
|
+
import type { Todo, TodoStatus } from "./types";
|
|
5
|
+
import { MAX_PANEL_ROWS, STATUS_STYLES } from "./constants";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** Panel header: collapsed/expanded icon plus per-status counts. */
|
|
8
|
+
export function getHeader(
|
|
8
9
|
todos: Todo[],
|
|
9
10
|
th: Theme,
|
|
10
|
-
|
|
11
|
-
isCollapsed?: boolean,
|
|
12
|
-
): string[] {
|
|
13
|
-
const indent = (str: string, level = 1) => " ".repeat(Math.abs(level)) + str;
|
|
14
|
-
|
|
15
|
-
const lines: string[] = [
|
|
16
|
-
truncateToWidth(
|
|
17
|
-
indent(getStyledTodoListHeader(todos, th, isCollapsed)),
|
|
18
|
-
width,
|
|
19
|
-
),
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
if (!isCollapsed) {
|
|
23
|
-
lines.push("");
|
|
24
|
-
|
|
25
|
-
if (todos.length === 0) {
|
|
26
|
-
lines.push(
|
|
27
|
-
truncateToWidth(
|
|
28
|
-
indent(th.fg("dim", "No todos yet. Ask the agent to add some!"), 2),
|
|
29
|
-
width,
|
|
30
|
-
),
|
|
31
|
-
);
|
|
32
|
-
} else {
|
|
33
|
-
const visible = todos.slice(0, MAX_PANEL_ROWS);
|
|
34
|
-
|
|
35
|
-
for (const t of visible) {
|
|
36
|
-
lines.push(
|
|
37
|
-
truncateToWidth(indent(getStyledTodo(t, todos, th), 2), width),
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (todos.length > visible.length) {
|
|
42
|
-
lines.push(
|
|
43
|
-
truncateToWidth(
|
|
44
|
-
indent(th.fg("dim", `… +${todos.length - visible.length} more`), 3),
|
|
45
|
-
width,
|
|
46
|
-
),
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
lines.push("");
|
|
53
|
-
|
|
54
|
-
return lines;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function getStyledTodoListHeader(
|
|
58
|
-
todos: Todo[],
|
|
59
|
-
th: Theme,
|
|
60
|
-
isCollapsed?: boolean,
|
|
11
|
+
isCollapsed: boolean,
|
|
61
12
|
): string {
|
|
62
13
|
const count = todos.reduce<Record<TodoStatus, number>>(
|
|
63
14
|
(acc, t) => {
|
|
@@ -67,27 +18,50 @@ export function getStyledTodoListHeader(
|
|
|
67
18
|
{ pending: 0, "in-progress": 0, completed: 0 },
|
|
68
19
|
);
|
|
69
20
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const statuses: TodoStatus[] = ["pending", "in-progress", "completed"];
|
|
73
|
-
|
|
74
|
-
const counter = statuses
|
|
21
|
+
const counter = (["pending", "in-progress", "completed"] as TodoStatus[])
|
|
75
22
|
.map((s) => {
|
|
76
|
-
const { icon, color
|
|
23
|
+
const { icon, color = "text" } =
|
|
24
|
+
STATUS_STYLES[s] ?? STATUS_STYLES.pending;
|
|
77
25
|
|
|
78
26
|
return th.fg(color, `${icon}${count[s] ?? 0}`);
|
|
79
27
|
})
|
|
80
28
|
.join(th.fg("dim", " / "));
|
|
81
29
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
30
|
+
const state = isCollapsed ? "collapsed" : "expanded";
|
|
31
|
+
|
|
32
|
+
return th.fg("accent", `${PANEL_STATE_ICON[state]} Todos - ${counter}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getSortedTodos(todos: Todo[]): Todo[] {
|
|
36
|
+
if (todos.length <= MAX_PANEL_ROWS) return todos;
|
|
37
|
+
|
|
38
|
+
const weights: Record<TodoStatus, number> = {
|
|
39
|
+
pending: 0,
|
|
40
|
+
completed: 2,
|
|
41
|
+
"in-progress": 1,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const sorted = todos.sort((a, b) => {
|
|
45
|
+
const wa = weights[a.status];
|
|
46
|
+
const wb = weights[b.status];
|
|
47
|
+
|
|
48
|
+
if (wa === wb) return a.id - b.id;
|
|
49
|
+
|
|
50
|
+
return wa - wb;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return sorted;
|
|
86
54
|
}
|
|
87
55
|
|
|
56
|
+
/** One panel/report row for a todo. */
|
|
88
57
|
export function getStyledTodo(t: Todo, todos: Todo[], th: Theme): string {
|
|
89
|
-
const {
|
|
90
|
-
|
|
58
|
+
const {
|
|
59
|
+
fg,
|
|
60
|
+
bold,
|
|
61
|
+
icon,
|
|
62
|
+
strikethrough,
|
|
63
|
+
color = "text",
|
|
64
|
+
} = STATUS_STYLES[t.status] ?? STATUS_STYLES.pending;
|
|
91
65
|
|
|
92
66
|
const check = th.fg(color, icon);
|
|
93
67
|
const id = th.fg("accent", `#${t.id}`);
|
package/src/widget.ts
CHANGED
|
@@ -1,76 +1,36 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
createPanelWidget,
|
|
4
|
+
type PanelWidgetSpec,
|
|
5
|
+
type SessionRecordStore,
|
|
6
|
+
} from "@leo-alvarenga/pi-ext-core";
|
|
7
|
+
|
|
8
|
+
import { MAX_PANEL_ROWS, PANEL_TOGGLE_CHORD, WIDGET_KEY } from "./constants";
|
|
9
9
|
import type { TodoState } from "./types";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
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
|
-
});
|
|
10
|
+
import { getHeader, getSortedTodos, getStyledTodo } from "./utils";
|
|
11
|
+
|
|
12
|
+
export function registerTodoWidget(
|
|
13
|
+
pi: ExtensionAPI,
|
|
14
|
+
store: SessionRecordStore<TodoState>,
|
|
15
|
+
) {
|
|
16
|
+
const spec: PanelWidgetSpec<TodoState> = {
|
|
17
|
+
store,
|
|
18
|
+
widgetKey: WIDGET_KEY,
|
|
19
|
+
maxRows: MAX_PANEL_ROWS,
|
|
20
|
+
toggleChord: PANEL_TOGGLE_CHORD,
|
|
21
|
+
moreLabel: (n) => `… +${n} more`,
|
|
22
|
+
emptyText: "No todos yet. Ask the agent to add some!",
|
|
23
|
+
header: (s, theme, isCollapsed) => getHeader(s.todos, theme, isCollapsed),
|
|
24
|
+
|
|
25
|
+
rows: (s, theme) =>
|
|
26
|
+
getSortedTodos(s.todos).map((t) => getStyledTodo(t, s.todos, theme)),
|
|
27
|
+
|
|
28
|
+
isEmpty: (s) =>
|
|
29
|
+
s.todos.length === 0 || s.todos.every((t) => t.status === "completed"),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const controls = createPanelWidget(pi, spec);
|
|
33
|
+
controls.register();
|
|
34
|
+
|
|
35
|
+
return controls;
|
|
76
36
|
}
|