pi-jar 0.1.2 → 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/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
- /** `done` mirrors `status === "completed"` for callers that only need open/closed. */
8
- export interface Todo { id: string; title: string; done: boolean; status: TodoStatus; activeForm?: string; details?: string }
9
- export interface TodoInput { title: string; status: TodoStatus; activeForm?: string }
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
- let running = 0;
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.status === "in_progress" && ++running > 1) return false;
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 { const item = [...this.items.values()].find((todo) => todo.status === "in_progress"); return item && { ...item }; }
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 task is in progress: starting one parks the previous 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
- if (!validTitle(event.title) || item) return false;
79
- this.items.set(event.id, make(event.id, event.title, "pending", undefined, event.details));
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.setItemStatus(item, event.done ? "completed" : "pending");
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.setItemStatus(item, event.status);
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(title: string, details?: string): Todo | undefined {
119
- if (!validTitle(title)) return undefined;
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 on invalid input.
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
- if (inputs.length > MAX_TODOS) return `At most ${MAX_TODOS} tasks.`;
130
- if (inputs.some((item) => !validTitle(item.title))) return "Every task needs a short, non-empty title.";
131
- if (inputs.some((item) => !validStatus(item.status))) return `status must be one of ${TODO_STATUSES.join(", ")}.`;
132
- if (inputs.filter((item) => item.status === "in_progress").length > 1) return "Only one task may be in_progress at a time.";
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 key = cleanText(item.title, 120).toLowerCase();
136
- free.set(key, [...(free.get(key) ?? []), item.id]);
241
+ const slot = key(item.parentId ?? "", item.title);
242
+ free.set(slot, [...(free.get(slot) ?? []), item.id]);
137
243
  }
138
- const items = inputs.map((item): WrittenTodo => {
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
- return { id, title: item.title, status: item.status, ...(activeForm ? { activeForm } : {}) };
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
- return item.status === status || this.commit({ v: 1, op: "status", id, status });
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
@@ -1,6 +1,7 @@
1
1
  import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { FLAME_RAMP, FLAME_WIDTH, flameFrame, rgb, supportsTruecolor } from "./flame.ts";
3
3
  import { cleanText } from "./status.ts";
4
+ import { iconSet, withIcon } from "./icons.ts";
4
5
 
5
6
  export type WelcomeAction = "settings" | "refresh" | "roles" | "plan" | "goal" | `resume:${number}`;
6
7
 
@@ -89,8 +90,11 @@ const center = (line: string, width: number) => {
89
90
  const left = Math.floor(room / 2);
90
91
  return " ".repeat(left) + line + " ".repeat(room - left);
91
92
  };
93
+ // truncateToWidth re-segments every grapheme even when a line already fits; visibleWidth is
94
+ // memoized by Pi, so measure first and only truncate real overflow (same output either way).
95
+ const fitTo = (line: string, width: number) => visibleWidth(line) <= width ? line : truncateToWidth(line, width);
92
96
  const fixedCell = (line: string, width: number) => {
93
- const value = truncateToWidth(line, width);
97
+ const value = fitTo(line, width);
94
98
  return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
95
99
  };
96
100
 
@@ -157,12 +161,12 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
157
161
  const label = (name: string, value: string, color: WelcomeColor = "muted") =>
158
162
  fg("dim", name.padEnd(labelWidth)) + fg(color, cleanText(value, 120));
159
163
 
160
- const header = [fg("accent", "pi-jar" + (info.version ? " " + info.version : "")),
161
- info.model ? fg("muted", cleanText(info.model, 40)) : fg("dim", "no model"),
164
+ const header = [fg("accent", withIcon("pi", "pi-jar" + (info.version ? " " + info.version : ""))),
165
+ info.model ? fg("muted", withIcon("model", cleanText(info.model, 40))) : fg("dim", "no model"),
162
166
  info.effort && info.effort !== "off" ? fg("muted", info.effort) : "",
163
167
  info.activeRole ? fg("warning", "role:" + cleanText(info.activeRole, 16)) : ""].filter(Boolean).join(fg("dim", " · "));
164
168
 
165
- const git = info.branch ? "git " + cleanText(info.branch, 24) + (info.dirty ? " · dirty" : "") : "git unavailable";
169
+ const git = info.branch ? withIcon("branch", cleanText(info.branch, 24)) + (info.dirty ? " · dirty" : "") : withIcon("branch", "unavailable");
166
170
  const quota = info.quota != null ? `quota ${Math.round(info.quota)}%` : `quota ${info.quotaEnabled ? "unavailable" : "off"}`;
167
171
  const plan = info.plan?.enabled ? "◆ planning" + (info.plan.title ? " · " + info.plan.title : "")
168
172
  : info.plan?.title ? "last · " + info.plan.title + ` (${info.plan.steps} steps)` : "none · /plan <idea>";
@@ -185,7 +189,7 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
185
189
  cell(center(fg("dim", "— welcome to ") + fg("accent", "pi-jar") + fg("dim", " —"), inner)),
186
190
  cell(""),
187
191
  divider,
188
- cell(label("PROJECT", (info.project || "unavailable") + " · " + git, info.dirty ? "warning" : "muted")),
192
+ cell(label("PROJECT", withIcon("folder", info.project || "unavailable") + " · " + git, info.dirty ? "warning" : "muted")),
189
193
  cell(label("SESSION", [info.context ?? "ctx ?", quota, info.cost ?? ""].filter(Boolean).join(" · "))),
190
194
  divider,
191
195
  cell(label("PLAN", plan, info.plan?.enabled ? "warning" : "muted")),
@@ -203,6 +207,21 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
203
207
  ];
204
208
  }
205
209
 
210
+ const CARD_COLORS: readonly WelcomeColor[] = ["accent", "warning", "error", "muted", "dim"];
211
+ let cardCache: { key: string; lines: string[]; widths: number[] } | undefined;
212
+
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, 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;
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;
224
+
206
225
  /** Which welcome action (if any) sits under a zero-based cell in the rendered lines. */
207
226
  export function welcomeHit(lines: readonly string[], x: number, y: number): WelcomeAction | undefined {
208
227
  const line = lines[y];
@@ -227,10 +246,17 @@ export function welcomeSettingsHit(lines: readonly string[], x: number, y: numbe
227
246
 
228
247
  export function welcomeLines(width: number, frame: number, fg: Paint, info: WelcomeInfo = {}): string[] {
229
248
  if (width <= 0) return [];
230
- const fit = (line: string) => truncateToWidth(line, width);
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[] {
231
256
  const seed = info.flameSeed ?? 1;
232
257
  const flame = flameFrame(frame, seed, (color, text) => fg(color, text));
233
258
  if (width < 32) {
259
+ const fit = (line: string) => fitTo(line, width);
234
260
  const start = Math.max(0, Math.floor((FLAME_WIDTH - width) / 2));
235
261
  return [
236
262
  ...flame.slice(4).map((line) => fit(sliceByColumn(line, start, Math.min(width, FLAME_WIDTH)))),
@@ -242,19 +268,26 @@ export function welcomeLines(width: number, frame: number, fg: Paint, info: Welc
242
268
  ];
243
269
  }
244
270
  const wide = width >= 72;
245
- const flameArt = flame.map((line) => center(fixedCell(line, FLAME_WIDTH), ART_WIDTH));
271
+ // FlameSim rows are exactly FLAME_WIDTH single-cell glyphs, so center them without measuring:
272
+ // re-measuring freshly colored rows every 150 ms tick was most of the welcome's render time.
273
+ const flameLeft = " ".repeat(Math.floor((ART_WIDTH - FLAME_WIDTH) / 2));
274
+ const flameRight = " ".repeat(ART_WIDTH - FLAME_WIDTH - flameLeft.length);
275
+ const flameArt = flame.map((line) => flameLeft + line + flameRight);
246
276
  if (!wide) {
247
- const art = [...flame.slice(2).map((line) => center(fixedCell(line, FLAME_WIDTH), Math.min(width, ART_WIDTH))),
248
- ...PI_COMPACT.map((line) => fg("accent", center(line, Math.min(width, ART_WIDTH))))];
249
- return [...art.map(fit), ...card(width, fg, info).map(fit), ""];
277
+ // width >= 32 here, so ART_WIDTH-wide art rows always fit.
278
+ const art = [...flameArt.slice(2), ...PI_COMPACT.map((line) => fg("accent", center(line, ART_WIDTH)))];
279
+ const details = cachedCard(width, fg, info, inputs);
280
+ return [...art, ...details.lines.map((line, index) => details.widths[index]! <= width ? line : truncateToWidth(line, width)), ""];
250
281
  }
251
282
  const art = [...flameArt, ...PI_LARGE.map((line) => fg("accent", center(fixedCell(line, 21), ART_WIDTH)))];
252
- const details = card(width - ART_WIDTH - 2, fg, info);
253
- 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));
254
285
  const artRows = [...Array.from({ length: topPad }, () => " ".repeat(ART_WIDTH)), ...art];
255
- 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) => {
256
287
  const left = artRows[index] ?? " ".repeat(ART_WIDTH);
257
- return fit(left + " " + (details[index] ?? ""));
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);
258
291
  });
259
292
  return [...merged, "", ""];
260
293
  }
package/src/working.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { icon } from "./icons.ts";
1
2
  import { cleanText } from "./status.ts";
2
3
 
3
4
  export type WorkingPhase = "idle" | "generating" | "tool" | "waiting";
@@ -54,7 +55,8 @@ export class WorkingState {
54
55
  const symbols = phase === "tool" ? ["◐", "◓", "◑", "◒"] : phase === "generating" ? ["✢", "✣", "✤", "✣"] : ["✢"];
55
56
  const frames = animations ? symbols.map((symbol) => fg(color, symbol)) : [fg(color, symbols[0] ?? "✢")];
56
57
  const details = phase === "idle" || this.startedAt === undefined ? "" :
57
- ` (${duration(elapsed)}${this.outputTokens ? ` · ↓ ${tokenCount(this.outputTokens)} tokens` : ""}${effort && effort !== "off" ? ` · ${effort} effort` : ""})`;
58
+ ` (${duration(elapsed)}${this.outputTokens ? ` · ↓ ${tokenCount(this.outputTokens)} tokens` : ""}${effort && effort !== "off" ? ` · ${effort} effort` : ""}`
59
+ + `${phase === "waiting" ? "" : ` · ${icon("esc") === "esc" ? "esc" : icon("esc") + " esc"} to interrupt`})`;
58
60
  return { message: word ? fg(color, word) + fg("muted", details) : undefined, frames, color };
59
61
  }
60
62
  }