@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo-alvarenga/pi-todo-list",
3
- "version": "0.5.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
- "tsx": "^4.19.0",
37
- "typescript": "^7.0.2"
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, groupByStatus } from "./core";
4
+ import { applyAction } from "./core";
5
+ import { groupByStatus } from "./query";
5
6
  import { REPORT_ENTRY } from "./constants";
6
- import type { TodoStore } from "./state";
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(pi: ExtensionAPI, store: TodoStore): void {
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 { TodoStatus, TodoStatusUi } from "./types";
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, TodoStatusUi> = {
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
- * 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
@@ -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 { TodoStore } from "./state";
5
+ import { createTodoStore } 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
- const store = new TodoStore(
11
- (snapshot) => pi.appendEntry(STATE_ENTRY, snapshot),
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) => store.replay(ctx));
21
- pi.on("session_tree", (_event, ctx) => store.replay(ctx));
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 { STATE_ENTRY } from "./constants";
4
- import type { TodoDetails, TodoState } from "./types";
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
- /** Rebuild the current session's state from the session branch (no disk writes). */
43
- 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);
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 { TodoStore } from "./state";
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(pi: ExtensionAPI, store: TodoStore): void {
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: TodoStore,
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 { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { PANEL_STATE_ICON } from "@leo-alvarenga/pi-ext-core";
3
3
 
4
- import { Todo, TodoStatus } from "./types";
5
- import { MAX_PANEL_ROWS, PANEL_STATE_ICON, STATUS_STYLES } from "./constants";
4
+ import type { Todo, TodoStatus } from "./types";
5
+ import { MAX_PANEL_ROWS, STATUS_STYLES } from "./constants";
6
6
 
7
- export function getStyledTodoList(
7
+ /** Panel header: collapsed/expanded icon plus per-status counts. */
8
+ export function getHeader(
8
9
  todos: Todo[],
9
10
  th: Theme,
10
- width: number,
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 collapseState = isCollapsed ? "collapsed" : "expanded";
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 } = STATUS_STYLES[s] ?? STATUS_STYLES.pending;
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
- return th.fg(
83
- "accent",
84
- `${PANEL_STATE_ICON[collapseState]} Todos - ${counter}`,
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 { icon, fg, bold, strikethrough, color } =
90
- STATUS_STYLES[t.status] ?? STATUS_STYLES.pending;
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
- ExtensionAPI,
3
- ExtensionContext,
4
- Theme,
5
- } from "@earendil-works/pi-coding-agent";
6
-
7
- import { PANEL_TOGGLE_CHORD, WIDGET_KEY } from "./constants";
8
- import type { TodoStore } from "./state";
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 { getStyledTodoList } from "./utils";
11
-
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
- }
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
  }