pi-jar 0.1.3 → 0.1.4
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 +33 -7
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +7 -4
- package/extensions/index.ts +55 -37
- package/package.json +1 -1
- package/src/activity-view.ts +314 -41
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate.ts +371 -65
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +34 -16
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +5 -9
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/tasks.ts
CHANGED
|
@@ -4,19 +4,25 @@ import { cleanText } from "./status.ts";
|
|
|
4
4
|
export const TASK_ENTRY = "pi-jar.task";
|
|
5
5
|
export const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
6
6
|
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* `done` mirrors `status === "completed"` for callers that only need open/closed. `parentId` marks a
|
|
9
|
+
* subtask of a top-level task (one level deep); a parent's status is derived from its subtasks.
|
|
10
|
+
*/
|
|
11
|
+
export interface Todo { id: string; title: string; done: boolean; status: TodoStatus; activeForm?: string; details?: string; parentId?: string }
|
|
12
|
+
/** A parent's own `status` is ignored when it has subtasks. */
|
|
13
|
+
export interface TodoInput { id?: string; title: string; status: TodoStatus; activeForm?: string; details?: string; subtasks?: readonly TodoInput[] }
|
|
14
|
+
export type TodoReplacement = Partial<Omit<TodoInput, "id">>;
|
|
15
|
+
/** Bounds the whole list: parents plus subtasks. */
|
|
10
16
|
export const MAX_TODOS = 50;
|
|
11
17
|
|
|
12
|
-
interface WrittenTodo { id: string; title: string; status: TodoStatus; activeForm?: string }
|
|
18
|
+
interface WrittenTodo { id: string; title: string; status: TodoStatus; activeForm?: string; details?: string; parentId?: string }
|
|
13
19
|
export type TodoEvent =
|
|
14
|
-
| { v: 1; op: "add"; id: string; title: string; details?: string }
|
|
20
|
+
| { v: 1; op: "add"; id: string; title: string; details?: string; parentId?: string }
|
|
15
21
|
| { v: 1; op: "edit"; id: string; title: string }
|
|
16
22
|
| { v: 1; op: "toggle"; id: string; done: boolean }
|
|
17
23
|
| { v: 1; op: "status"; id: string; status: TodoStatus }
|
|
18
24
|
| { v: 1; op: "delete"; id: string }
|
|
19
|
-
/** Full-list replacement, like a Claude todo write. */
|
|
25
|
+
/** Full-list replacement, like a Claude todo write. Subtasks follow a parent listed earlier. */
|
|
20
26
|
| { v: 1; op: "write"; items: WrittenTodo[] };
|
|
21
27
|
|
|
22
28
|
const validId = (id: unknown): id is string => typeof id === "string" && /^[a-zA-Z0-9-]{1,64}$/.test(id);
|
|
@@ -25,37 +31,113 @@ const validStatus = (status: unknown): status is TodoStatus => TODO_STATUSES.inc
|
|
|
25
31
|
const validWrite = (event: Record<string, unknown>): boolean => {
|
|
26
32
|
if (event.v !== 1 || event.op !== "write" || !Array.isArray(event.items) || event.items.length > MAX_TODOS) return false;
|
|
27
33
|
const ids = new Set<string>();
|
|
28
|
-
|
|
34
|
+
const top = new Set<string>();
|
|
35
|
+
const parents = new Set<string>();
|
|
29
36
|
for (const raw of event.items as unknown[]) {
|
|
30
37
|
const item = raw as Record<string, unknown> | null;
|
|
31
38
|
if (!item || !validId(item.id) || !validTitle(item.title) || !validStatus(item.status) || ids.has(item.id)) return false;
|
|
32
39
|
ids.add(item.id);
|
|
33
|
-
if (item.
|
|
40
|
+
if (item.parentId === undefined) top.add(item.id);
|
|
41
|
+
else if (validId(item.parentId) && top.has(item.parentId)) parents.add(item.parentId);
|
|
42
|
+
else return false;
|
|
34
43
|
}
|
|
44
|
+
// Only leaves run; a parent's in_progress is derived.
|
|
45
|
+
let running = 0;
|
|
46
|
+
for (const raw of event.items as Record<string, unknown>[]) if (raw.status === "in_progress" && !parents.has(raw.id as string) && ++running > 1) return false;
|
|
35
47
|
return true;
|
|
36
48
|
};
|
|
37
|
-
const make = (id: string, title: string, status: TodoStatus, activeForm?: unknown, details?: unknown): Todo => ({
|
|
49
|
+
const make = (id: string, title: string, status: TodoStatus, activeForm?: unknown, details?: unknown, parentId?: unknown): Todo => ({
|
|
38
50
|
id, title: cleanText(title, 120), status, done: status === "completed",
|
|
39
51
|
...(typeof activeForm === "string" && cleanText(activeForm, 80) ? { activeForm: cleanText(activeForm, 80) } : {}),
|
|
40
|
-
...(typeof details === "string" && cleanText(details, 240) ? { details: cleanText(details, 240) } : {})
|
|
52
|
+
...(typeof details === "string" && cleanText(details, 240) ? { details: cleanText(details, 240) } : {}),
|
|
53
|
+
...(typeof parentId === "string" ? { parentId } : {})
|
|
41
54
|
});
|
|
55
|
+
/** Parent rollup: all subtasks completed → completed; any running or partly completed → in_progress. */
|
|
56
|
+
const rolled = (done: number, running: number, total: number): TodoStatus => done === total ? "completed" : running || done ? "in_progress" : "pending";
|
|
57
|
+
/** Top-level tasks in order, each followed by its subtasks; orphans are dropped. */
|
|
58
|
+
const grouped = (items: ReadonlyMap<string, Todo>): Map<string, Todo> => {
|
|
59
|
+
const kids = new Map<string, Todo[]>();
|
|
60
|
+
for (const item of items.values()) {
|
|
61
|
+
if (!item.parentId) continue;
|
|
62
|
+
const list = kids.get(item.parentId);
|
|
63
|
+
if (list) list.push(item); else kids.set(item.parentId, [item]);
|
|
64
|
+
}
|
|
65
|
+
const next = new Map<string, Todo>();
|
|
66
|
+
for (const item of items.values()) {
|
|
67
|
+
if (item.parentId) continue;
|
|
68
|
+
next.set(item.id, item);
|
|
69
|
+
for (const kid of kids.get(item.id) ?? []) next.set(kid.id, kid);
|
|
70
|
+
}
|
|
71
|
+
return next;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Subtask counts for a parent; `{ done: 0, total: 0 }` when it has none. */
|
|
75
|
+
export function todoProgress(items: readonly Todo[], id: string): { done: number; total: number } {
|
|
76
|
+
let done = 0, total = 0;
|
|
77
|
+
for (const item of items) if (item.parentId === id) { total++; if (item.done) done++; }
|
|
78
|
+
return { done, total };
|
|
79
|
+
}
|
|
80
|
+
/** Completion counted over leaf tasks: subtasks plus top-level tasks without subtasks. */
|
|
81
|
+
export function todoTotals(items: readonly Todo[]): { done: number; total: number } {
|
|
82
|
+
const parents = new Set<string>();
|
|
83
|
+
for (const item of items) if (item.parentId) parents.add(item.parentId);
|
|
84
|
+
let done = 0, total = 0;
|
|
85
|
+
for (const item of items) if (!parents.has(item.id)) { total++; if (item.done) done++; }
|
|
86
|
+
return { done, total };
|
|
87
|
+
}
|
|
42
88
|
|
|
43
89
|
/** Session-local, append-only changes: only the current Pi branch is replayed. */
|
|
44
90
|
export class TodoStore {
|
|
45
91
|
private items = new Map<string, Todo>();
|
|
46
92
|
private readonly append: (event: TodoEvent) => void;
|
|
47
93
|
constructor(append: (event: TodoEvent) => void) { this.append = append; }
|
|
94
|
+
/** Display order: each top-level task is followed by its subtasks. */
|
|
48
95
|
all(): Todo[] { return [...this.items.values()].map((item) => ({ ...item })); }
|
|
49
96
|
get(id: string): Todo | undefined { const item = this.items.get(id); return item && { ...item }; }
|
|
50
|
-
/** The task being worked on right now, if any. */
|
|
51
|
-
current(): Todo | undefined {
|
|
97
|
+
/** The leaf task being worked on right now, if any. */
|
|
98
|
+
current(): Todo | undefined {
|
|
99
|
+
const parents = new Set<string>();
|
|
100
|
+
for (const item of this.items.values()) if (item.parentId) parents.add(item.parentId);
|
|
101
|
+
const item = [...this.items.values()].find((todo) => todo.status === "in_progress" && !parents.has(todo.id));
|
|
102
|
+
return item && { ...item };
|
|
103
|
+
}
|
|
52
104
|
|
|
105
|
+
private children(id: string): Todo[] { return [...this.items.values()].filter((item) => item.parentId === id); }
|
|
53
106
|
private setItemStatus(item: Todo, status: TodoStatus): void {
|
|
54
|
-
// Exactly one
|
|
107
|
+
// Exactly one leaf is in progress: starting one parks the previous one (parents re-derive in rollup).
|
|
55
108
|
if (status === "in_progress") for (const other of this.items.values()) if (other !== item && other.status === "in_progress") { other.status = "pending"; other.done = false; }
|
|
56
109
|
item.status = status;
|
|
57
110
|
item.done = status === "completed";
|
|
58
111
|
}
|
|
112
|
+
/** Parent actions cascade: start runs the first open subtask, done/open apply to every subtask. */
|
|
113
|
+
private setTree(item: Todo, status: TodoStatus): boolean {
|
|
114
|
+
const kids = this.children(item.id);
|
|
115
|
+
if (!kids.length) this.setItemStatus(item, status);
|
|
116
|
+
else if (status === "in_progress") {
|
|
117
|
+
const next = kids.find((kid) => kid.status === "in_progress") ?? kids.find((kid) => kid.status !== "completed");
|
|
118
|
+
if (!next) return false;
|
|
119
|
+
this.setItemStatus(next, status);
|
|
120
|
+
} else for (const kid of kids) this.setItemStatus(kid, status);
|
|
121
|
+
this.rollup();
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
private rollup(): void {
|
|
125
|
+
const counts = new Map<string, { done: number; running: number; total: number }>();
|
|
126
|
+
for (const item of this.items.values()) {
|
|
127
|
+
if (!item.parentId) continue;
|
|
128
|
+
const count = counts.get(item.parentId) ?? { done: 0, running: 0, total: 0 };
|
|
129
|
+
count.total++;
|
|
130
|
+
if (item.status === "completed") count.done++;
|
|
131
|
+
else if (item.status === "in_progress") count.running++;
|
|
132
|
+
counts.set(item.parentId, count);
|
|
133
|
+
}
|
|
134
|
+
for (const [id, count] of counts) {
|
|
135
|
+
const parent = this.items.get(id);
|
|
136
|
+
if (!parent) continue;
|
|
137
|
+
parent.status = rolled(count.done, count.running, count.total);
|
|
138
|
+
parent.done = parent.status === "completed";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
59
141
|
|
|
60
142
|
apply(raw: unknown): boolean {
|
|
61
143
|
if (!raw || typeof raw !== "object") return false;
|
|
@@ -66,31 +148,38 @@ export class TodoStore {
|
|
|
66
148
|
const next = new Map<string, Todo>();
|
|
67
149
|
for (const raw of event.items as unknown[]) {
|
|
68
150
|
const item = raw as Record<string, unknown>;
|
|
69
|
-
next.set(item.id as string, make(item.id as string, item.title as string, item.status as TodoStatus, item.activeForm, this.items.get(item.id as string)?.details));
|
|
151
|
+
next.set(item.id as string, make(item.id as string, item.title as string, item.status as TodoStatus, item.activeForm, item.details ?? this.items.get(item.id as string)?.details, item.parentId));
|
|
70
152
|
}
|
|
71
|
-
this.items = next;
|
|
153
|
+
this.items = grouped(next);
|
|
154
|
+
this.rollup();
|
|
72
155
|
return true;
|
|
73
156
|
}
|
|
74
157
|
if (!validId(event.id)) return false;
|
|
75
158
|
const item = this.items.get(event.id);
|
|
76
159
|
switch (event.op) {
|
|
77
|
-
case "add":
|
|
78
|
-
|
|
79
|
-
|
|
160
|
+
case "add": {
|
|
161
|
+
const parent = validId(event.parentId) ? this.items.get(event.parentId) : undefined;
|
|
162
|
+
if (!validTitle(event.title) || item || (event.parentId !== undefined && (!parent || parent.parentId))) return false;
|
|
163
|
+
this.items.set(event.id, make(event.id, event.title, "pending", undefined, event.details, event.parentId));
|
|
164
|
+
if (parent) { this.items = grouped(this.items); this.rollup(); }
|
|
80
165
|
return true;
|
|
166
|
+
}
|
|
81
167
|
case "edit":
|
|
82
168
|
if (!validTitle(event.title) || !item) return false;
|
|
83
169
|
item.title = cleanText(event.title, 120);
|
|
84
170
|
return true;
|
|
85
171
|
case "toggle":
|
|
86
172
|
if (typeof event.done !== "boolean" || !item) return false;
|
|
87
|
-
this.
|
|
88
|
-
return true;
|
|
173
|
+
return this.setTree(item, event.done ? "completed" : "pending");
|
|
89
174
|
case "status":
|
|
90
175
|
if (!validStatus(event.status) || !item) return false;
|
|
91
|
-
this.
|
|
176
|
+
return this.setTree(item, event.status);
|
|
177
|
+
case "delete":
|
|
178
|
+
if (!item) return false;
|
|
179
|
+
this.items.delete(event.id);
|
|
180
|
+
for (const kid of this.children(event.id)) this.items.delete(kid.id);
|
|
181
|
+
this.rollup();
|
|
92
182
|
return true;
|
|
93
|
-
case "delete": return this.items.delete(event.id);
|
|
94
183
|
default: return false;
|
|
95
184
|
}
|
|
96
185
|
}
|
|
@@ -115,38 +204,134 @@ export class TodoStore {
|
|
|
115
204
|
try { this.append(event); return true; }
|
|
116
205
|
catch { this.items = before; return false; }
|
|
117
206
|
}
|
|
118
|
-
add
|
|
119
|
-
|
|
207
|
+
/** Why `add` would be rejected, as a message for the model; undefined when it is valid. */
|
|
208
|
+
addError(title: string, parentId?: string): string | undefined {
|
|
209
|
+
if (!validTitle(title)) return "add needs a short, non-empty title.";
|
|
210
|
+
if (this.items.size >= MAX_TODOS) return `At most ${MAX_TODOS} tasks including subtasks.`;
|
|
211
|
+
if (parentId === undefined) return undefined;
|
|
212
|
+
const parent = this.items.get(parentId);
|
|
213
|
+
if (!parent) return `parent ${parentId} is not a task id from jar_todo list.`;
|
|
214
|
+
if (parent.parentId) return `parent ${parentId} is a subtask; subtasks nest only one level under a top-level task.`;
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
add(title: string, details?: string, parentId?: string): Todo | undefined {
|
|
218
|
+
if (this.addError(title, parentId)) return undefined;
|
|
120
219
|
const id = randomUUID();
|
|
121
220
|
const cleanDetails = typeof details === "string" && details.trim() ? cleanText(details, 240) : undefined;
|
|
122
|
-
return this.commit({ v: 1, op: "add", id, title, ...(cleanDetails ? { details: cleanDetails } : {}) }) ? this.get(id) : undefined;
|
|
221
|
+
return this.commit({ v: 1, op: "add", id, title, ...(cleanDetails ? { details: cleanDetails } : {}), ...(parentId ? { parentId } : {}) }) ? this.get(id) : undefined;
|
|
123
222
|
}
|
|
124
223
|
/**
|
|
125
|
-
* Replace the whole list. Items keep their id when an existing task has the same title
|
|
126
|
-
* so history and the goal loop see continuity. Returns an error message
|
|
224
|
+
* Replace the whole list. Items keep their id when an existing task has the same title (subtasks:
|
|
225
|
+
* within the same parent), so history and the goal loop see continuity. Returns an error message
|
|
226
|
+
* on invalid input.
|
|
127
227
|
*/
|
|
128
228
|
write(inputs: readonly TodoInput[]): string | undefined {
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
if (
|
|
229
|
+
const malformed = this.inputError(inputs);
|
|
230
|
+
if (malformed) return malformed;
|
|
231
|
+
const flat = inputs.flatMap((item) => [item, ...(item.subtasks ?? [])]);
|
|
232
|
+
if (flat.length > MAX_TODOS) return `At most ${MAX_TODOS} tasks including subtasks.`;
|
|
233
|
+
if (inputs.some((item) => item.subtasks?.some((sub) => sub.subtasks?.length))) return "Subtasks cannot have their own subtasks; nest only one level.";
|
|
234
|
+
if (flat.some((item) => !validTitle(item.title))) return "Every task and subtask needs a short, non-empty title.";
|
|
235
|
+
if (flat.some((item) => !validStatus(item.status))) return `status must be one of ${TODO_STATUSES.join(", ")}.`;
|
|
236
|
+
const leaves = inputs.flatMap((item) => item.subtasks?.length ? item.subtasks : [item]);
|
|
237
|
+
if (leaves.filter((item) => item.status === "in_progress").length > 1) return "Only one task may be in_progress at a time (a parent's status follows its subtasks).";
|
|
238
|
+
const key = (parentId: string, title: string) => parentId + "\n" + cleanText(title, 120).toLowerCase();
|
|
133
239
|
const free = new Map<string, string[]>();
|
|
134
240
|
for (const item of this.items.values()) {
|
|
135
|
-
const
|
|
136
|
-
free.set(
|
|
241
|
+
const slot = key(item.parentId ?? "", item.title);
|
|
242
|
+
free.set(slot, [...(free.get(slot) ?? []), item.id]);
|
|
137
243
|
}
|
|
138
|
-
const
|
|
139
|
-
const id = free.get(cleanText(item.title, 120).toLowerCase())?.shift() ?? randomUUID();
|
|
244
|
+
const entry = (item: TodoInput, id: string, status: TodoStatus, parentId?: string): WrittenTodo => {
|
|
140
245
|
const activeForm = typeof item.activeForm === "string" && item.activeForm.trim() ? item.activeForm : undefined;
|
|
141
|
-
|
|
142
|
-
|
|
246
|
+
const details = item.details ?? this.items.get(id)?.details;
|
|
247
|
+
return { id, title: item.title, status, ...(activeForm ? { activeForm } : {}), ...(details ? { details } : {}), ...(parentId ? { parentId } : {}) };
|
|
248
|
+
};
|
|
249
|
+
const supplied = new Set<string>();
|
|
250
|
+
for (const item of flat) if (item.id !== undefined) {
|
|
251
|
+
if (!validId(item.id) || supplied.has(item.id) || !this.items.has(item.id)) return "Explicit ids must be unique existing task ids.";
|
|
252
|
+
supplied.add(item.id);
|
|
253
|
+
}
|
|
254
|
+
const take = (item: TodoInput, parentId: string): string => {
|
|
255
|
+
if (item.id) return item.id;
|
|
256
|
+
const ids = free.get(key(parentId, item.title));
|
|
257
|
+
while (ids?.length) { const id = ids.shift()!; if (!supplied.has(id)) return id; }
|
|
258
|
+
return randomUUID();
|
|
259
|
+
};
|
|
260
|
+
const items: WrittenTodo[] = [];
|
|
261
|
+
for (const item of inputs) {
|
|
262
|
+
const id = take(item, "");
|
|
263
|
+
if (item.id && this.items.get(item.id)?.parentId) return "A task id cannot move to a different parent.";
|
|
264
|
+
const subtasks = item.subtasks ?? [];
|
|
265
|
+
const done = subtasks.filter((sub) => sub.status === "completed").length;
|
|
266
|
+
const running = subtasks.filter((sub) => sub.status === "in_progress").length;
|
|
267
|
+
items.push(entry(item, id, subtasks.length ? rolled(done, running, subtasks.length) : item.status));
|
|
268
|
+
for (const sub of subtasks) {
|
|
269
|
+
if (sub.id && this.items.get(sub.id)?.parentId !== id) return "A task id cannot move to a different parent.";
|
|
270
|
+
items.push(entry(sub, take(sub, id), sub.status, id));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
143
273
|
return this.commit({ v: 1, op: "write", items }) ? undefined : "Could not save the task list.";
|
|
144
274
|
}
|
|
275
|
+
/** Validate before flattening: malformed runtime input must never erase the current list. */
|
|
276
|
+
private inputError(inputs: readonly TodoInput[]): string | undefined {
|
|
277
|
+
if (!Array.isArray(inputs)) return "todos must be an array.";
|
|
278
|
+
for (const item of inputs) {
|
|
279
|
+
if (!item || typeof item !== "object" || !validTitle(item.title) || !validStatus(item.status)) return "Every task needs a short, non-empty title and a valid status.";
|
|
280
|
+
if (item.activeForm !== undefined && typeof item.activeForm !== "string") return "activeForm must be a string.";
|
|
281
|
+
if (item.details !== undefined && typeof item.details !== "string") return "details must be a string.";
|
|
282
|
+
if (item.subtasks !== undefined) {
|
|
283
|
+
if (!Array.isArray(item.subtasks)) return "subtasks must be an array.";
|
|
284
|
+
for (const sub of item.subtasks) {
|
|
285
|
+
if (!sub || typeof sub !== "object") return "Every subtask must be an object.";
|
|
286
|
+
if (sub.subtasks !== undefined && (!Array.isArray(sub.subtasks) || sub.subtasks.length)) return "Subtasks cannot have their own subtasks; nest only one level.";
|
|
287
|
+
}
|
|
288
|
+
const error = this.inputError(item.subtasks);
|
|
289
|
+
if (error) return error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
private snapshot(): TodoInput[] {
|
|
295
|
+
return this.all().filter((item) => !item.parentId).map((item) => ({
|
|
296
|
+
id: item.id, title: item.title, status: item.status, activeForm: item.activeForm,
|
|
297
|
+
subtasks: this.children(item.id).map((sub) => ({ id: sub.id, title: sub.title, status: sub.status, activeForm: sub.activeForm }))
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
/** Atomically append a batch; unaffected ids and leaf statuses are retained. */
|
|
301
|
+
appendTodos(inputs: readonly TodoInput[], parentId?: string): string | undefined {
|
|
302
|
+
const error = this.inputError(inputs);
|
|
303
|
+
if (error) return error;
|
|
304
|
+
if (!inputs.length) return "append needs at least one task.";
|
|
305
|
+
if (inputs.some((item) => item.id !== undefined || item.subtasks?.some((sub) => sub.id !== undefined))) return "Appended tasks must not supply existing ids.";
|
|
306
|
+
const next = this.snapshot();
|
|
307
|
+
if (parentId !== undefined) {
|
|
308
|
+
const parent = next.find((item) => item.id === parentId);
|
|
309
|
+
if (!parent) return "parent must be an existing top-level task id.";
|
|
310
|
+
if (inputs.some((item) => item.subtasks?.length)) return "Subtasks cannot have their own subtasks; nest only one level.";
|
|
311
|
+
parent.subtasks = [...(parent.subtasks ?? []), ...inputs];
|
|
312
|
+
} else next.push(...inputs);
|
|
313
|
+
return this.write(next);
|
|
314
|
+
}
|
|
315
|
+
/** Replace only the addressed task. Omitted fields and subtasks are preserved. */
|
|
316
|
+
replace(id: string, replacement: TodoReplacement): string | undefined {
|
|
317
|
+
if (!this.items.has(id)) return "replace needs a valid existing task id.";
|
|
318
|
+
if (!replacement || typeof replacement !== "object" || Array.isArray(replacement)) return "replace needs task fields.";
|
|
319
|
+
if ("id" in replacement && replacement.id !== id) return "Replacement id must match the addressed task.";
|
|
320
|
+
if (!Object.keys(replacement).some((key) => ["title", "status", "activeForm", "subtasks"].includes(key))) return "replace needs at least one task field.";
|
|
321
|
+
const next = this.snapshot();
|
|
322
|
+
const target = next.flatMap((item) => [item, ...(item.subtasks ?? [])]).find((item) => item.id === id)!;
|
|
323
|
+
if (this.items.get(id)?.parentId && replacement.subtasks?.length) return "Subtasks cannot have their own subtasks; nest only one level.";
|
|
324
|
+
Object.assign(target, replacement, { id });
|
|
325
|
+
return this.write(next);
|
|
326
|
+
}
|
|
145
327
|
edit(id: string, title: string): boolean { return this.commit({ v: 1, op: "edit", id, title }); }
|
|
328
|
+
/** On a parent, start runs its first open subtask; done/open complete or reopen all subtasks. */
|
|
146
329
|
setStatus(id: string, status: TodoStatus): boolean {
|
|
147
330
|
const item = this.items.get(id);
|
|
148
331
|
if (!item) return false;
|
|
149
|
-
|
|
332
|
+
const kids = status === "in_progress" ? this.children(id) : [];
|
|
333
|
+
const unchanged = kids.length ? kids.some((kid) => kid.status === "in_progress") : item.status === status;
|
|
334
|
+
return unchanged || this.commit({ v: 1, op: "status", id, status });
|
|
150
335
|
}
|
|
151
336
|
setDone(id: string, done: boolean): boolean {
|
|
152
337
|
const item = this.items.get(id);
|
|
@@ -157,6 +342,7 @@ export class TodoStore {
|
|
|
157
342
|
const item = this.items.get(id);
|
|
158
343
|
return !!item && this.setDone(id, !item.done);
|
|
159
344
|
}
|
|
345
|
+
/** Deleting a parent deletes its subtasks. */
|
|
160
346
|
delete(id: string): boolean { return this.commit({ v: 1, op: "delete", id }); }
|
|
161
347
|
}
|
|
162
348
|
|
package/src/welcome.ts
CHANGED
|
@@ -208,14 +208,19 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
const CARD_COLORS: readonly WelcomeColor[] = ["accent", "warning", "error", "muted", "dim"];
|
|
211
|
-
let cardCache: { key: string; lines: string[] } | undefined;
|
|
211
|
+
let cardCache: { key: string; lines: string[]; widths: number[] } | undefined;
|
|
212
212
|
|
|
213
213
|
/** The card only changes with its inputs and theme; the flame beside it repaints every tick. */
|
|
214
|
-
function cachedCard(width: number, fg: Paint, info: WelcomeInfo): string[] {
|
|
215
|
-
const key = `${width}\0${
|
|
216
|
-
if (cardCache?.key !== key)
|
|
217
|
-
|
|
214
|
+
function cachedCard(width: number, fg: Paint, info: WelcomeInfo, inputs: string): { lines: string[]; widths: number[] } {
|
|
215
|
+
const key = `${width}\0${inputs}`;
|
|
216
|
+
if (cardCache?.key !== key) {
|
|
217
|
+
const lines = card(width, fg, info);
|
|
218
|
+
cardCache = { key, lines, widths: lines.map((line) => visibleWidth(line)) };
|
|
219
|
+
}
|
|
220
|
+
return cardCache;
|
|
218
221
|
}
|
|
222
|
+
/** Pi repaints the welcome on every render, not only on flame ticks; identical inputs reuse the last lines. */
|
|
223
|
+
let welcomeCache: { key: string; lines: string[] } | undefined;
|
|
219
224
|
|
|
220
225
|
/** Which welcome action (if any) sits under a zero-based cell in the rendered lines. */
|
|
221
226
|
export function welcomeHit(lines: readonly string[], x: number, y: number): WelcomeAction | undefined {
|
|
@@ -241,10 +246,17 @@ export function welcomeSettingsHit(lines: readonly string[], x: number, y: numbe
|
|
|
241
246
|
|
|
242
247
|
export function welcomeLines(width: number, frame: number, fg: Paint, info: WelcomeInfo = {}): string[] {
|
|
243
248
|
if (width <= 0) return [];
|
|
244
|
-
const
|
|
249
|
+
const inputs = `${supportsTruecolor()}\0${iconSet()}\0${CARD_COLORS.map((color) => fg(color, "x")).join("")}\0${JSON.stringify(info)}`;
|
|
250
|
+
const key = `${width}\0${frame}\0${inputs}`;
|
|
251
|
+
if (welcomeCache?.key !== key) welcomeCache = { key, lines: renderWelcome(width, frame, fg, info, inputs) };
|
|
252
|
+
return welcomeCache.lines.slice();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderWelcome(width: number, frame: number, fg: Paint, info: WelcomeInfo, inputs: string): string[] {
|
|
245
256
|
const seed = info.flameSeed ?? 1;
|
|
246
257
|
const flame = flameFrame(frame, seed, (color, text) => fg(color, text));
|
|
247
258
|
if (width < 32) {
|
|
259
|
+
const fit = (line: string) => fitTo(line, width);
|
|
248
260
|
const start = Math.max(0, Math.floor((FLAME_WIDTH - width) / 2));
|
|
249
261
|
return [
|
|
250
262
|
...flame.slice(4).map((line) => fit(sliceByColumn(line, start, Math.min(width, FLAME_WIDTH)))),
|
|
@@ -264,17 +276,18 @@ export function welcomeLines(width: number, frame: number, fg: Paint, info: Welc
|
|
|
264
276
|
if (!wide) {
|
|
265
277
|
// width >= 32 here, so ART_WIDTH-wide art rows always fit.
|
|
266
278
|
const art = [...flameArt.slice(2), ...PI_COMPACT.map((line) => fg("accent", center(line, ART_WIDTH)))];
|
|
267
|
-
|
|
279
|
+
const details = cachedCard(width, fg, info, inputs);
|
|
280
|
+
return [...art, ...details.lines.map((line, index) => details.widths[index]! <= width ? line : truncateToWidth(line, width)), ""];
|
|
268
281
|
}
|
|
269
282
|
const art = [...flameArt, ...PI_LARGE.map((line) => fg("accent", center(fixedCell(line, 21), ART_WIDTH)))];
|
|
270
|
-
const details = cachedCard(width - ART_WIDTH - 2, fg, info);
|
|
271
|
-
const topPad = Math.max(0, Math.floor((details.length - art.length) / 2));
|
|
283
|
+
const details = cachedCard(width - ART_WIDTH - 2, fg, info, inputs);
|
|
284
|
+
const topPad = Math.max(0, Math.floor((details.lines.length - art.length) / 2));
|
|
272
285
|
const artRows = [...Array.from({ length: topPad }, () => " ".repeat(ART_WIDTH)), ...art];
|
|
273
|
-
const merged = Array.from({ length: Math.max(artRows.length, details.length) }, (_, index) => {
|
|
286
|
+
const merged = Array.from({ length: Math.max(artRows.length, details.lines.length) }, (_, index) => {
|
|
274
287
|
const left = artRows[index] ?? " ".repeat(ART_WIDTH);
|
|
275
|
-
const right = details[index] ?? "";
|
|
276
|
-
// Every art row is ART_WIDTH cells
|
|
277
|
-
return ART_WIDTH + 2 +
|
|
288
|
+
const right = details.lines[index] ?? "";
|
|
289
|
+
// Every art row is ART_WIDTH cells and card widths are measured once per card.
|
|
290
|
+
return ART_WIDTH + 2 + (details.widths[index] ?? 0) <= width ? left + " " + right : truncateToWidth(left + " " + right, width);
|
|
278
291
|
});
|
|
279
292
|
return [...merged, "", ""];
|
|
280
293
|
}
|