@astralyn/pi 0.87.2 → 0.87.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/bundle/chunks/{chunk-NBIUJDV6.js → chunk-MJ45I4SM.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-RFLBIA5Z.js → chunk-TYOQD6VQ.js} +33 -20
  4. package/dist/bundle/chunks/{virtual-modules-GOHLE5JL.js → virtual-modules-NDGCHNG7.js} +1 -1
  5. package/dist/bundle/cli.js +1 -1
  6. package/dist/bundle/index.js +1 -1
  7. package/dist/bundle/rpc-entry.js +1 -1
  8. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
  9. package/dist/core/tools/tool-definition-wrapper.js +114 -1
  10. package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
  11. package/dist/extensions/background/actions.d.ts.map +1 -1
  12. package/dist/extensions/background/actions.js +107 -55
  13. package/dist/extensions/background/actions.js.map +1 -1
  14. package/dist/extensions/background/index.js +3 -3
  15. package/dist/extensions/background/index.js.map +1 -1
  16. package/dist/extensions/question/index.d.ts.map +1 -1
  17. package/dist/extensions/question/index.js +3 -1
  18. package/dist/extensions/question/index.js.map +1 -1
  19. package/dist/extensions/question/schema.d.ts +18 -0
  20. package/dist/extensions/question/schema.d.ts.map +1 -1
  21. package/dist/extensions/question/schema.js +52 -0
  22. package/dist/extensions/question/schema.js.map +1 -1
  23. package/dist/extensions/subagent/index.d.ts.map +1 -1
  24. package/dist/extensions/subagent/index.js +3 -1
  25. package/dist/extensions/subagent/index.js.map +1 -1
  26. package/dist/extensions/subagent/resolve.d.ts +14 -1
  27. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  28. package/dist/extensions/subagent/resolve.js +32 -18
  29. package/dist/extensions/subagent/resolve.js.map +1 -1
  30. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  31. package/dist/extensions/subagent/runner.js +4 -1
  32. package/dist/extensions/subagent/runner.js.map +1 -1
  33. package/dist/extensions/subagent/schema.d.ts +9 -0
  34. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  35. package/dist/extensions/subagent/schema.js +30 -1
  36. package/dist/extensions/subagent/schema.js.map +1 -1
  37. package/dist/extensions/subagent/types.d.ts +2 -0
  38. package/dist/extensions/subagent/types.d.ts.map +1 -1
  39. package/dist/extensions/subagent/types.js.map +1 -1
  40. package/dist/extensions/todo/constants.d.ts +1 -1
  41. package/dist/extensions/todo/constants.d.ts.map +1 -1
  42. package/dist/extensions/todo/constants.js +8 -8
  43. package/dist/extensions/todo/constants.js.map +1 -1
  44. package/dist/extensions/todo/index.d.ts.map +1 -1
  45. package/dist/extensions/todo/index.js +1 -0
  46. package/dist/extensions/todo/index.js.map +1 -1
  47. package/dist/extensions/todo/schema.d.ts +30 -25
  48. package/dist/extensions/todo/schema.d.ts.map +1 -1
  49. package/dist/extensions/todo/schema.js +26 -20
  50. package/dist/extensions/todo/schema.js.map +1 -1
  51. package/dist/extensions/todo/state.d.ts +12 -8
  52. package/dist/extensions/todo/state.d.ts.map +1 -1
  53. package/dist/extensions/todo/state.js +255 -171
  54. package/dist/extensions/todo/state.js.map +1 -1
  55. package/dist/extensions/todo/view.d.ts +6 -2
  56. package/dist/extensions/todo/view.d.ts.map +1 -1
  57. package/dist/extensions/todo/view.js +234 -144
  58. package/dist/extensions/todo/view.js.map +1 -1
  59. package/docs/bundled/extensions/background.md +1 -1
  60. package/docs/bundled/extensions/question.md +1 -0
  61. package/docs/bundled/extensions/subagent.md +3 -1
  62. package/docs/bundled/extensions/todo.md +35 -23
  63. package/docs/extensions.md +2 -0
  64. package/npm-shrinkwrap.json +2 -2
  65. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * todo/view.ts — v2 presentation: the one-line above-editor widget renderer,
2
+ * todo/view.ts — v3 presentation: the one-line above-editor widget renderer,
3
3
  * tool call/group renderers, the /todos list formatter, and model-facing
4
4
  * result text. Wire input (args, details, snapshots) is read defensively and
5
5
  * bounded so partial or hostile input cannot grow output without limit.
@@ -144,39 +144,58 @@ function formatTodoList(state) {
144
144
  }
145
145
  /** /todos command output: full list, subject line plus indented description per task. */
146
146
  export const formatCommandList = formatTodoList;
147
- /** Model-facing text for one completed todo call; bounded by list and input limits. */
147
+ // --- Model-facing result text ------------------------------------------------
148
+ /** `#1–#3` for a consecutive run, `#1, #2` otherwise. */
149
+ function formatIdRange(ids) {
150
+ const consecutive = ids.length > 1 && ids.every((id, index) => id === ids[0] + index);
151
+ return consecutive ? `#${ids[0]}–#${ids[ids.length - 1]}` : ids.map((id) => `#${id}`).join(", ");
152
+ }
153
+ /**
154
+ * One-line change summary for a completed call, e.g.
155
+ * `Created 1 task: #7: Wire parser (in_progress); Updated #1 (pending -> completed): S; demoted #5 to pending; auto-removed completed #1–#3 to stay within 20`.
156
+ * A call that changed nothing (including `{}`) returns the full list.
157
+ */
148
158
  export function formatTodoContent(change, state) {
149
- switch (change.kind) {
150
- case "create": {
151
- const parts = change.ids.map((id) => {
152
- const item = state.items.find((entry) => entry.id === id);
153
- return item ? `#${item.id}: ${item.subject}` : `#${id}`;
154
- });
155
- const noun = change.ids.length === 1 ? "task" : "tasks";
156
- return `Created ${change.ids.length} ${noun}${parts.length ? `: ${parts.join("; ")}` : ""}`;
157
- }
158
- case "update": {
159
- const item = state.items.find((entry) => entry.id === change.id);
160
- const subject = item ? `: ${item.subject}` : "";
161
- const transition = change.from === change.to ? "" : ` (${change.from} -> ${change.to})`;
162
- const demoted = change.demotedId !== undefined ? `; demoted #${change.demotedId} to pending` : "";
163
- return `Updated #${change.id}${transition}${subject}${demoted}`;
164
- }
165
- case "list":
166
- return formatTodoList(state);
167
- case "delete": {
168
- const noun = change.removed.length === 1 ? "task" : "tasks";
169
- const parts = change.removed.map((entry) => `#${entry.id}: ${entry.subject}`);
170
- return `Deleted ${change.removed.length} ${noun}${parts.length ? `: ${parts.join("; ")}` : ""}`;
171
- }
159
+ const parts = [];
160
+ if (change.created.length > 0) {
161
+ const labels = change.created.map((id) => {
162
+ const item = state.items.find((entry) => entry.id === id);
163
+ if (!item)
164
+ return `#${id}`;
165
+ const suffix = item.status === "pending" ? "" : ` (${item.status})`;
166
+ return `#${id}: ${item.subject}${suffix}`;
167
+ });
168
+ const noun = change.created.length === 1 ? "task" : "tasks";
169
+ parts.push(`Created ${change.created.length} ${noun}: ${labels.join("; ")}`);
170
+ }
171
+ for (const entry of change.updated) {
172
+ const item = state.items.find((candidate) => candidate.id === entry.id);
173
+ const subject = item ? `: ${item.subject}` : "";
174
+ const transition = entry.from === entry.to ? "" : ` (${entry.from} -> ${entry.to})`;
175
+ parts.push(`Updated #${entry.id}${transition}${subject}`);
176
+ }
177
+ if (change.demotedId !== undefined)
178
+ parts.push(`demoted #${change.demotedId} to pending`);
179
+ if (change.deleted.length > 0) {
180
+ const noun = change.deleted.length === 1 ? "task" : "tasks";
181
+ const labels = change.deleted.map((entry) => `#${entry.id}: ${entry.subject}`);
182
+ parts.push(`Deleted ${change.deleted.length} ${noun}: ${labels.join("; ")}`);
183
+ }
184
+ for (const id of change.absent)
185
+ parts.push(`#${id} already absent`);
186
+ if (change.evicted.length > 0) {
187
+ const ids = change.evicted.map((entry) => entry.id);
188
+ parts.push(`auto-removed completed ${formatIdRange(ids)} to stay within ${TODO_MAX_ITEMS}`);
172
189
  }
190
+ if (parts.length === 0)
191
+ return formatTodoList(state);
192
+ return parts.join("; ");
173
193
  }
174
194
  const CALL_ITEMS_MAX = TODO_MAX_BATCH_ITEMS;
175
195
  const CALL_SUBJECT_PREVIEW_COUNT = 2;
176
196
  const CALL_SUBJECT_PREVIEW_WIDTH = 72;
177
197
  const CALL_DESCRIPTION_PREVIEW_LENGTH = 120;
178
198
  const GROUP_FAILURE_MAX_LENGTH = 120;
179
- const TODO_ACTIONS = new Set(["create", "update", "list", "delete"]);
180
199
  /** Record-like read; proxies that throw on inspection are treated as absent. */
181
200
  function safeRecord(value) {
182
201
  try {
@@ -235,36 +254,55 @@ function readArray(value, limit = CALL_ITEMS_MAX) {
235
254
  return undefined;
236
255
  }
237
256
  }
238
- /** Id lists: lenient for delete args (invalid entries drop), strict for result changes (any invalid entry rejects). */
239
- function readIds(value, strict) {
257
+ /** Id lists: invalid entries drop (rendering never rejects the whole call). */
258
+ function readIds(value) {
240
259
  const array = readArray(value);
241
- if (strict && (!array || array.length === 0 || array.length > CALL_ITEMS_MAX))
242
- return undefined;
243
260
  if (!array)
244
- return strict ? undefined : [];
261
+ return undefined;
245
262
  const ids = [];
246
263
  for (const entry of array.values) {
247
264
  const id = safeId(entry);
248
- if (id === undefined) {
249
- if (strict)
250
- return undefined;
251
- continue;
252
- }
253
- ids.push(id);
265
+ if (id !== undefined)
266
+ ids.push(id);
254
267
  }
255
268
  return ids;
256
269
  }
257
270
  /** Create args: sparse or invalid entries drop; the batch caps at the maximum. */
258
- function batchItems(value) {
271
+ function batchCreateItems(value) {
259
272
  const result = [];
260
273
  for (const raw of readArray(value)?.values ?? []) {
261
274
  const item = safeRecord(raw);
262
275
  if (!item)
263
276
  continue;
264
- result.push({
277
+ const entry = {
265
278
  subject: safeSubject(safeValue(item, "subject")),
266
279
  description: safeDescription(safeValue(item, "description")),
267
- });
280
+ };
281
+ const status = safeStatus(safeValue(item, "status"));
282
+ if (status)
283
+ entry.status = status;
284
+ result.push(entry);
285
+ }
286
+ return result;
287
+ }
288
+ /** Update args: sparse or invalid entries drop; the batch caps at the maximum. */
289
+ function batchUpdateItems(value) {
290
+ const result = [];
291
+ for (const raw of readArray(value)?.values ?? []) {
292
+ const item = safeRecord(raw);
293
+ if (!item)
294
+ continue;
295
+ const entry = {
296
+ subject: safeSubject(safeValue(item, "subject")),
297
+ description: safeDescription(safeValue(item, "description")),
298
+ };
299
+ const id = safeId(safeValue(item, "id"));
300
+ if (id !== undefined)
301
+ entry.id = id;
302
+ const status = safeStatus(safeValue(item, "status"));
303
+ if (status)
304
+ entry.status = status;
305
+ result.push(entry);
268
306
  }
269
307
  return result;
270
308
  }
@@ -287,7 +325,7 @@ function formatSubjectPreview(subjects, total) {
287
325
  const hidden = Math.max(0, total - shown.length);
288
326
  return `${shown.join(", ")}${hidden ? `, +${hidden} more` : ""}`;
289
327
  }
290
- /** v2 details guard shared by the call and group renderers. */
328
+ /** v3 details guard shared by the call and group renderers. */
291
329
  function todoDetails(value) {
292
330
  const details = safeRecord(value);
293
331
  if (!details || safeValue(details, "schemaVersion") !== TODO_DETAILS_SCHEMA_VERSION)
@@ -296,83 +334,118 @@ function todoDetails(value) {
296
334
  const state = safeRecord(safeValue(details, "state"));
297
335
  if (!change || !state)
298
336
  return undefined;
299
- const kind = safeString(safeValue(change, "kind"));
300
- if (!kind || !TODO_ACTIONS.has(kind))
301
- return undefined;
302
337
  return { change, state };
303
338
  }
304
339
  /** Result ids for an expanded create, only when they match the shown items. */
305
340
  function createResultIds(result, count) {
306
341
  const details = todoDetails(result?.details);
307
- if (!details || safeString(safeValue(details.change, "kind")) !== "create")
342
+ if (!details)
308
343
  return undefined;
309
- const ids = readIds(safeValue(details.change, "ids"), true);
310
- return ids?.length === count ? ids : undefined;
344
+ const ids = readIds(safeValue(details.change, "created"));
345
+ return ids && ids.length === count && count > 0 ? ids : undefined;
311
346
  }
312
- /** Removed entries for an expanded delete; absent until a v2 delete result arrives. */
347
+ /** Removed entries from a settled result, for the expanded delete detail lines. */
313
348
  function deleteResultRemoved(result) {
314
349
  const details = todoDetails(result?.details);
315
- if (!details || safeString(safeValue(details.change, "kind")) !== "delete")
316
- return undefined;
317
- return removedEntries(safeValue(details.change, "removed"));
350
+ if (!details)
351
+ return [];
352
+ return idSubjectEntries(safeValue(details.change, "deleted"));
353
+ }
354
+ /** Absent ids from a settled delete, for the expanded delete detail lines. */
355
+ function deleteResultAbsent(result) {
356
+ const details = todoDetails(result?.details);
357
+ if (!details)
358
+ return [];
359
+ return readIds(safeValue(details.change, "absent")) ?? [];
318
360
  }
319
361
  /** Headline plus the parameters the result never echoes as detail lines. */
320
362
  function formatCallParts(args, theme, result) {
321
- const action = clipText(args?.action, TODO_MAX_SUBJECT_LENGTH);
322
- const verb = TODO_ACTIONS.has(action) ? action : undefined;
323
- const headline = [theme.fg("toolTitle", theme.bold(verb ? `todo ${verb}` : "todo"))];
324
363
  const details = [];
325
- if (verb === "create") {
326
- const items = batchItems(args?.items);
327
- const rawCount = readArray(args?.items)?.length;
328
- if (rawCount !== undefined) {
329
- const count = Math.min(rawCount, CALL_ITEMS_MAX);
330
- headline.push(theme.fg("dim", `${count} ${count === 1 ? "task" : "tasks"}`));
331
- const subjects = items.map((item) => item.subject);
332
- const preview = formatSubjectPreview(subjects, count);
333
- if (preview)
334
- headline.push(theme.fg("dim", "·"), theme.fg("text", preview));
364
+ const segments = [];
365
+ const pushSegment = (verb, body) => {
366
+ const title = segments.length === 0 ? `todo ${verb}` : verb;
367
+ segments.push(body ? `${theme.fg("toolTitle", theme.bold(title))} ${body}` : theme.fg("toolTitle", theme.bold(title)));
368
+ };
369
+ const hasCreate = args?.create !== undefined;
370
+ const hasUpdate = args?.update !== undefined;
371
+ const hasDelete = args?.delete !== undefined;
372
+ if (hasCreate) {
373
+ const items = batchCreateItems(args?.create);
374
+ const rawCount = readArray(args?.create)?.length;
375
+ // An empty array is strict-mode filler: the call lists the tasks, so the
376
+ // group earns no segment of its own.
377
+ if (rawCount !== 0) {
378
+ const body = [];
379
+ if (rawCount !== undefined) {
380
+ const count = Math.min(rawCount, CALL_ITEMS_MAX);
381
+ body.push(theme.fg("dim", `${count} ${count === 1 ? "task" : "tasks"}`));
382
+ const preview = formatSubjectPreview(items.map((item) => item.subject), count);
383
+ if (preview)
384
+ body.push(theme.fg("dim", "·"), theme.fg("text", preview));
385
+ }
386
+ pushSegment("create", body.join(" "));
335
387
  }
336
388
  const ids = createResultIds(result, items.length);
337
389
  for (const [index, item] of items.entries()) {
338
390
  const marker = ids ? theme.fg("accent", `#${ids[index]}`) : theme.fg("accent", `${index + 1}.`);
339
- details.push(item.subject ? `${marker} ${theme.fg("text", item.subject)}` : marker);
391
+ const status = item.status && item.status !== "pending"
392
+ ? ` ${theme.fg(STATUS_COLOR[item.status], `(${item.status})`)}`
393
+ : "";
394
+ details.push(item.subject ? `${marker} ${theme.fg("text", item.subject)}${status}` : marker);
340
395
  if (item.description)
341
396
  details.push(` ${theme.fg("dim", descriptionPreview(item.description))}`);
342
397
  }
343
398
  }
344
- else if (verb === "update") {
345
- const id = safeId(args?.id);
346
- if (id !== undefined)
347
- headline.push(theme.fg("accent", `#${id}`));
348
- const status = safeStatus(args?.status);
349
- if (status)
350
- headline.push(theme.fg(STATUS_COLOR[status], status));
351
- const subject = safeSubject(args?.subject);
352
- if (subject)
353
- headline.push(theme.fg("text", truncateToWidth(subject, CALL_SUBJECT_PREVIEW_WIDTH, "…")));
354
- const description = safeDescription(args?.description);
355
- if (description)
356
- details.push(` ${theme.fg("dim", descriptionPreview(description))}`);
399
+ if (hasUpdate) {
400
+ const items = batchUpdateItems(args?.update);
401
+ const rawCount = readArray(args?.update)?.length;
402
+ if (rawCount !== 0) {
403
+ const entries = [];
404
+ for (const item of items) {
405
+ const parts = [];
406
+ if (item.id !== undefined)
407
+ parts.push(theme.fg("accent", `#${item.id}`));
408
+ if (item.status)
409
+ parts.push(theme.fg(STATUS_COLOR[item.status], item.status));
410
+ if (item.subject)
411
+ parts.push(theme.fg("text", truncateToWidth(item.subject, CALL_SUBJECT_PREVIEW_WIDTH, "…")));
412
+ if (parts.length > 0)
413
+ entries.push(parts.join(" "));
414
+ }
415
+ pushSegment("update", entries.join(theme.fg("dim", ", ")));
416
+ }
417
+ for (const item of items) {
418
+ if (item.description)
419
+ details.push(` ${theme.fg("dim", descriptionPreview(item.description))}`);
420
+ }
357
421
  }
358
- else if (verb === "delete") {
359
- const ids = readIds(args?.ids, false) ?? [];
360
- if (ids.length)
361
- headline.push(theme.fg("accent", ids.map((id) => `#${id}`).join(", ")));
362
- // The headline already carries the ids, so details only earn their line
363
- // once the result names what was actually removed.
364
- for (const entry of deleteResultRemoved(result) ?? []) {
422
+ if (hasDelete) {
423
+ const rawCount = readArray(args?.delete)?.length;
424
+ if (rawCount !== 0) {
425
+ const ids = readIds(args?.delete) ?? [];
426
+ pushSegment("delete", ids.length > 0 ? theme.fg("accent", ids.map((id) => `#${id}`).join(", ")) : "");
427
+ }
428
+ // The headline already carries the ids, so details only earn their lines
429
+ // once the result names what was actually removed or already absent.
430
+ for (const entry of deleteResultRemoved(result)) {
365
431
  details.push(`${theme.fg("accent", `#${entry.id}`)} ${theme.fg("text", entry.subject)}`);
366
432
  }
433
+ for (const id of deleteResultAbsent(result)) {
434
+ details.push(theme.fg("dim", `#${id} already absent`));
435
+ }
367
436
  }
368
- return { headline: headline.join(" "), details };
437
+ if (segments.length === 0) {
438
+ // No group fields at all: the call lists the tasks.
439
+ segments.push(theme.fg("toolTitle", theme.bold("todo list")));
440
+ }
441
+ return { headline: segments.join(theme.fg("dim", " ; ")), details };
369
442
  }
370
443
  /** One-line call summary when collapsed; headline plus details when expanded. */
371
444
  export function formatTodoCall(args, theme, expanded, result) {
372
445
  const { headline, details } = formatCallParts(args, theme, result);
373
446
  return !expanded || details.length === 0 ? headline : [headline, ...details].join("\n");
374
447
  }
375
- /** Id -> item index plus status counts over the v2 snapshot (one defensive walk). */
448
+ /** Id -> item index plus status counts over the v3 snapshot (one defensive walk). */
376
449
  function stateIndex(state) {
377
450
  const array = readArray(safeValue(state, "items"), TODO_MAX_ITEMS);
378
451
  if (!array || array.length > TODO_MAX_ITEMS)
@@ -400,81 +473,98 @@ function stateSubject(map, id) {
400
473
  const item = map.get(id);
401
474
  return item ? safeSubject(safeValue(item, "subject")) : "";
402
475
  }
403
- /** Deleted entries: one invalid or empty entry rejects the whole list. */
404
- function removedEntries(value) {
405
- const array = readArray(value);
406
- if (!array || array.length === 0 || array.length > CALL_ITEMS_MAX)
407
- return undefined;
408
- const removed = [];
409
- for (const raw of array.values) {
410
- const id = safeId(safeValue(safeRecord(raw), "id"));
411
- const subject = safeSubject(safeValue(safeRecord(raw), "subject"));
476
+ /** Id+subject entries (deleted, evicted): invalid entries drop. */
477
+ function idSubjectEntries(value) {
478
+ const entries = [];
479
+ for (const raw of readArray(value)?.values ?? []) {
480
+ const item = safeRecord(raw);
481
+ const id = safeId(safeValue(item, "id"));
482
+ const subject = safeSubject(safeValue(item, "subject"));
412
483
  if (id === undefined || !subject)
413
- return undefined;
414
- removed.push({ id, subject });
484
+ continue;
485
+ entries.push({ id, subject });
486
+ }
487
+ return entries;
488
+ }
489
+ /** Updated entries: {id, from, to} records, invalid entries drop. */
490
+ function updatedEntries(value) {
491
+ const entries = [];
492
+ for (const raw of readArray(value)?.values ?? []) {
493
+ const item = safeRecord(raw);
494
+ const id = safeId(safeValue(item, "id"));
495
+ if (id === undefined)
496
+ continue;
497
+ const entry = { id };
498
+ const from = safeStatus(safeValue(item, "from"));
499
+ if (from)
500
+ entry.from = from;
501
+ const to = safeStatus(safeValue(item, "to"));
502
+ if (to)
503
+ entry.to = to;
504
+ entries.push(entry);
415
505
  }
416
- return removed;
506
+ return entries;
417
507
  }
418
508
  function formatTodoGroupSuccess(args, theme, result) {
419
509
  const parsed = todoDetails(result.details);
420
510
  if (!parsed)
421
511
  return undefined;
422
512
  const { change, state } = parsed;
423
- const verb = (word) => theme.fg("toolTitle", `todo ${word}`);
513
+ const index = stateIndex(state);
514
+ const segments = [];
515
+ const pushVerb = (word, body) => {
516
+ const title = segments.length === 0 ? `todo ${word}` : word;
517
+ segments.push(body ? `${theme.fg("toolTitle", title)} ${body}` : theme.fg("toolTitle", title));
518
+ };
424
519
  const withPreview = (word, ids, subjects) => {
425
520
  const text = formatSubjectPreview(subjects, subjects.length);
426
- const consecutive = ids.length > 1 && ids.every((id, index) => id === ids[0] + index);
427
- const rendered = consecutive ? `#${ids[0]}–#${ids.at(-1)}` : ids.map((id) => `#${id}`).join(", ");
428
- const base = `${verb(word)} ${theme.fg("accent", rendered)}`;
429
- return text ? `${base}${theme.fg("dim", " · ")}${theme.fg("text", text)}` : base;
521
+ const base = theme.fg("accent", formatIdRange(ids));
522
+ pushVerb(word, text ? `${base}${theme.fg("dim", " · ")}${theme.fg("text", text)}` : base);
430
523
  };
431
- const kind = safeString(safeValue(change, "kind"));
432
- if (kind === "create") {
433
- const ids = readIds(safeValue(change, "ids"), true);
434
- const index = stateIndex(state);
435
- if (!ids || !index)
436
- return undefined;
437
- const subjects = ids.map((id) => stateSubject(index.byId, id));
438
- return withPreview("created", ids, subjects);
524
+ const created = readIds(safeValue(change, "created")) ?? [];
525
+ if (created.length > 0 && index) {
526
+ withPreview("created", created, created.map((id) => stateSubject(index.byId, id)));
439
527
  }
440
- if (kind === "update") {
441
- const id = safeId(safeValue(change, "id"));
442
- const index = stateIndex(state);
443
- if (id === undefined || !index)
444
- return undefined;
445
- const to = safeStatus(safeValue(change, "to"));
446
- const demotedId = safeId(safeValue(change, "demotedId"));
447
- const parts = [verb("updated"), theme.fg("accent", `#${id}`)];
448
- if (to)
449
- parts.push(theme.fg(STATUS_COLOR[to], to));
450
- const subject = stateSubject(index.byId, id) || safeSubject(args?.subject) || "";
528
+ for (const entry of updatedEntries(safeValue(change, "updated"))) {
529
+ const parts = [theme.fg("accent", `#${entry.id}`)];
530
+ if (entry.to)
531
+ parts.push(theme.fg(STATUS_COLOR[entry.to], entry.to));
532
+ const subject = index ? stateSubject(index.byId, entry.id) : "";
451
533
  if (subject)
452
534
  parts.push(theme.fg("text", truncateToWidth(subject, CALL_SUBJECT_PREVIEW_WIDTH, "…")));
453
- if (demotedId !== undefined)
454
- parts.push(theme.fg("dim", `· demoted #${demotedId}`));
455
- return parts.join(" ");
535
+ pushVerb("updated", parts.join(" "));
456
536
  }
457
- if (kind === "list") {
458
- const counts = stateIndex(state)?.counts;
459
- if (!counts)
460
- return undefined;
461
- const parts = [];
462
- if (counts.inProgress > 0)
463
- parts.push(`${counts.inProgress} in progress`);
464
- if (counts.pending > 0)
465
- parts.push(`${counts.pending} pending`);
466
- if (counts.completed > 0)
467
- parts.push(`${counts.completed} completed`);
468
- return `${verb("list")}: ${theme.fg("dim", parts.length ? parts.join(", ") : "no tasks")}`;
537
+ const demotedId = safeId(safeValue(change, "demotedId"));
538
+ if (demotedId !== undefined)
539
+ segments.push(theme.fg("dim", `demoted #${demotedId}`));
540
+ const deleted = idSubjectEntries(safeValue(change, "deleted"));
541
+ if (deleted.length > 0) {
542
+ withPreview("deleted", deleted.map((entry) => entry.id), deleted.map((entry) => entry.subject));
469
543
  }
470
- if (kind === "delete") {
471
- const removed = removedEntries(safeValue(change, "removed"));
472
- if (!removed)
544
+ for (const id of readIds(safeValue(change, "absent")) ?? []) {
545
+ segments.push(theme.fg("dim", `#${id} already absent`));
546
+ }
547
+ const evicted = idSubjectEntries(safeValue(change, "evicted"));
548
+ if (evicted.length > 0) {
549
+ segments.push(theme.fg("dim", `auto-removed ${evicted.map((entry) => `#${entry.id}`).join(", ")}`));
550
+ }
551
+ if (segments.length === 0) {
552
+ // Empty change: the call listed the tasks.
553
+ if (!index)
473
554
  return undefined;
474
- const subjects = removed.map((entry) => entry.subject);
475
- return withPreview("deleted", removed.map((entry) => entry.id), subjects);
555
+ const parts = [];
556
+ if (index.counts.inProgress > 0)
557
+ parts.push(`${index.counts.inProgress} in progress`);
558
+ if (index.counts.pending > 0)
559
+ parts.push(`${index.counts.pending} pending`);
560
+ if (index.counts.completed > 0)
561
+ parts.push(`${index.counts.completed} completed`);
562
+ return `${theme.fg("toolTitle", "todo list")}: ${theme.fg("dim", parts.length ? parts.join(", ") : "no tasks")}`;
476
563
  }
477
- return undefined;
564
+ // The update segment can still pick up the requested subject when the state
565
+ // no longer holds it (e.g. the task was deleted by a hostile snapshot).
566
+ void args;
567
+ return segments.join(theme.fg("dim", " ; "));
478
568
  }
479
569
  /** Compact result-aware summary used only by collapsed todo tool groups. */
480
570
  export function formatTodoGroupCall(args, theme, context) {