pi-jar 0.1.4 → 0.1.5

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.
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
4
4
  import { ICON_SETS } from "./icons.ts";
5
- import { GOAL_ROUND_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
5
+ import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
6
6
 
7
7
  const LABELS: Record<FooterField, string> = {
8
8
  model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
@@ -35,25 +35,37 @@ export async function openJarSettings(
35
35
  let width = 64;
36
36
  const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
37
37
  name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
38
- const fields = () => page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 5 : 7;
38
+ let choosing: "goalRounds" | "maxSubagents" | undefined;
39
+ const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
40
+ const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 6 : 7;
41
+ const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
39
42
  const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
40
- const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - 9));
43
+ const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
44
+ const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - (explainPool() ? 10 : 9)));
41
45
  const firstVisible = () => Math.min(Math.max(0, selected - pageSize() + 1), Math.max(0, fields() - pageSize()));
42
46
  const visibleCount = () => Math.min(pageSize(), fields());
43
47
  const apply = (index: number) => {
44
48
  const state = current();
45
- if (page === "footer") {
49
+ if (choosing) {
50
+ if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
51
+ else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
52
+ finishChoice();
53
+ } else if (page === "footer") {
46
54
  const field = FOOTER_FIELDS[index];
47
55
  if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
48
56
  } else if (page === "pi") {
49
57
  if (index === 0 && pi) { try { pi.setFullscreen(!pi.get().fullscreen); ctx.ui.notify("Pi TUI mode saved; restart Pi to apply", "info"); } catch (error) { piError(error); } }
50
58
  else if (index === 1 && pi) { try { pi.setCopyOnSelect(!pi.get().copyOnSelect); } catch (error) { piError(error); } }
51
59
  else if (index === 2) {
52
- const at = GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never);
53
- update({ ...state, goalRounds: GOAL_ROUND_CHOICES[(at + 1) % GOAL_ROUND_CHOICES.length]! });
60
+ choosing = "goalRounds";
61
+ selected = Math.max(0, GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never));
54
62
  }
55
- else if (index === 3) update({ ...state, advisor: !state.advisor });
56
- else if (index === 4) update({ ...state, advisorGates: !state.advisorGates });
63
+ else if (index === 3) {
64
+ choosing = "maxSubagents";
65
+ selected = Math.max(0, MAX_SUBAGENT_CHOICES.indexOf(state.maxSubagents));
66
+ }
67
+ else if (index === 4) update({ ...state, advisor: !state.advisor });
68
+ else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
57
69
  } else if (index === 0) {
58
70
  const at = Math.max(0, accents.indexOf(state.accent));
59
71
  update({ ...state, accent: accents[(at + 1) % accents.length]! });
@@ -77,7 +89,12 @@ export async function openJarSettings(
77
89
  return {
78
90
  invalidate() {},
79
91
  handleInput(data: string) {
80
- if (matchesKey(data, Key.escape) || data === "q") { done(); return; }
92
+ if (matchesKey(data, Key.escape) || data === "q") {
93
+ if (choosing) { finishChoice(); tui.requestRender(); } else done();
94
+ return;
95
+ }
96
+ if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
97
+ || matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left))) return;
81
98
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
82
99
  page = PAGES[(PAGES.indexOf(page) + 1) % PAGES.length]!;
83
100
  selected = 0;
@@ -97,6 +114,7 @@ export async function openJarSettings(
97
114
  if (event.type !== "click" || event.button !== "left") return;
98
115
  if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
99
116
  if (event.y === 1) {
117
+ choosing = undefined;
100
118
  if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
101
119
  else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
102
120
  else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
@@ -121,14 +139,17 @@ export async function openJarSettings(
121
139
  + " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]"));
122
140
  const prefs = pi?.get();
123
141
  const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
124
- const allRows = page === "footer"
142
+ const allRows = choosing
143
+ ? choices().map((choice, index) => row(index, String(choice), state[choosing!] === choice ? "CURRENT" : "", true))
144
+ : page === "footer"
125
145
  ? FOOTER_FIELDS.map((field, index) => row(index, LABELS[field], state.footer[field] ? "ON" : "OFF", state.footer[field]))
126
146
  : page === "pi" ? [
127
147
  row(0, "Mouse clicks (Pi fullscreen, restart)", !prefs ? "N/A" : prefs.fullscreen ? "ON" : "OFF", !!prefs?.fullscreen),
128
148
  row(1, "Copy on select", !prefs ? "N/A" : prefs.copyOnSelect ? "ON" : "OFF", !!prefs?.copyOnSelect),
129
149
  row(2, "Goal auto rounds", String(state.goalRounds), true),
130
- row(3, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
131
- row(4, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
150
+ row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
151
+ row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
152
+ row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
132
153
  ] : [
133
154
  row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
134
155
  row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
@@ -141,11 +162,16 @@ export async function openJarSettings(
141
162
  const start = firstVisible();
142
163
  const rows = allRows.slice(start, start + pageSize());
143
164
  return [top, tabs, divider,
144
- line(theme.fg("accent", page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
145
- line(theme.fg("dim", page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
165
+ line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
166
+ : choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
167
+ line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
168
+ : choosing ? " Select a value; Enter/click to save, Esc to cancel"
169
+ : page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
146
170
  : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
147
171
  ...rows,
148
- line(theme.fg("dim", " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
172
+ line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
173
+ : " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
174
+ ...(explainPool() ? [line(theme.fg("dim", " Lowering does not terminate existing subagents"))] : []),
149
175
  theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")];
150
176
  }
151
177
  };
package/src/settings.ts CHANGED
@@ -17,6 +17,8 @@ export interface JarVisualSettings {
17
17
  suggestions: boolean;
18
18
  /** Automatic implement/audit rounds per user message while a goal is active. */
19
19
  goalRounds: number;
20
+ /** Retained live subagent pool, including idle/paused agents; lowering does not terminate them. */
21
+ maxSubagents: MaxSubagents;
20
22
  /** The jar_advisor second-opinion tool and /advisor. */
21
23
  advisor: boolean;
22
24
  /** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
@@ -27,10 +29,15 @@ export interface JarVisualSettings {
27
29
  }
28
30
 
29
31
  export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
32
+ export const MAX_SUBAGENT_CHOICES = [2, 4, 6, 8, 16] as const;
33
+ export type MaxSubagents = (typeof MAX_SUBAGENT_CHOICES)[number];
34
+ export function isMaxSubagents(value: unknown): value is MaxSubagents {
35
+ return MAX_SUBAGENT_CHOICES.some((choice) => choice === value);
36
+ }
30
37
 
31
38
  export const SETTINGS_FILE = "pi-jar-settings.json";
32
39
  export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
33
- return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
40
+ return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
34
41
  }
35
42
 
36
43
  /** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
@@ -60,6 +67,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
60
67
  suggestions: typeof value.suggestions === "boolean" ? value.suggestions : fallback.suggestions,
61
68
  goalRounds: typeof value.goalRounds === "number" && Number.isInteger(value.goalRounds) && value.goalRounds >= 1 && value.goalRounds <= 50
62
69
  ? value.goalRounds : fallback.goalRounds,
70
+ maxSubagents: isMaxSubagents(value.maxSubagents) ? value.maxSubagents : fallback.maxSubagents,
63
71
  advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
64
72
  advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
65
73
  icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
package/src/status.ts CHANGED
@@ -73,7 +73,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
73
73
  const id = rawKey.slice(ROLE_PREFIX.length);
74
74
  if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
75
75
  const role = cachedRole(id, rawValue);
76
- if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ROLE_TTL_MS) {
76
+ const ttl = id.startsWith("delegate-") ? 24 * 60 * 60_000 : ROLE_TTL_MS;
77
+ if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ttl) {
77
78
  extras.push(`${id}: unavailable`);
78
79
  continue;
79
80
  }
package/src/task-tool.ts CHANGED
@@ -4,7 +4,7 @@ import { Type } from "typebox";
4
4
  import type { Todo, TodoInput, TodoStatus } from "./tasks.ts";
5
5
  import { TODO_STATUSES, TodoStore, todoMark, todoProgress, todoTotals } from "./tasks.ts";
6
6
 
7
- const ACTIONS = ["write", "list", "append", "remove", "replace", "add", "start", "done", "open", "edit", "delete"] as const;
7
+ const ACTIONS = ["write", "list", "append", "remove", "update", "add", "start", "done", "open", "edit", "delete"] as const;
8
8
  const Action = Type.Unsafe<(typeof ACTIONS)[number]>({ type: "string", enum: [...ACTIONS] });
9
9
  const Status = Type.Unsafe<(typeof TODO_STATUSES)[number]>({ type: "string", enum: [...TODO_STATUSES] });
10
10
  const Content = Type.String({ description: "Imperative task title, e.g. \"Run the tests\"." });
@@ -19,10 +19,10 @@ const Parameters = Type.Object({
19
19
  subtasks: Type.Optional(Type.Array(Type.Object({ id: Type.Optional(Type.String()), content: Content, status: Status, activeForm: ActiveForm }), {
20
20
  description: "Optional steps of this task (one level deep). The task's status then follows its subtasks."
21
21
  }))
22
- }), { description: "For append: new tasks only. For replace: exactly one replacement task. With write (or no action): the complete list, replacing it." })),
22
+ }), { description: "For append: new tasks only. For update: exactly one updated task. With write (or no action): the complete list, replacing it." })),
23
23
  action: Type.Optional(Action),
24
- id: Type.Optional(Type.String({ description: "Stable task id for replace/remove/start/done/open/edit/delete. On a parent, start runs its first open subtask; done/open apply to all its subtasks." })),
25
- title: Type.Optional(Type.String({ description: "Task title for append/add/edit/replace." })),
24
+ id: Type.Optional(Type.String({ description: "Stable task id for update/remove/start/done/open/edit/delete. On a parent, start runs its first open subtask; done/open apply to all its subtasks." })),
25
+ title: Type.Optional(Type.String({ description: "Task title for append/add/edit/update." })),
26
26
  status: Type.Optional(Status),
27
27
  activeForm: ActiveForm,
28
28
  parent: Type.Optional(Type.String({ description: "For append/add: id of a top-level task to add the new task under as a subtask." })),
@@ -42,6 +42,8 @@ interface TaskToolDetails {
42
42
  changed?: string;
43
43
  /** Full stable id index, including items outside the bounded preview. */
44
44
  ids?: { id: string; title: string; parentId?: string }[];
45
+ /** Optional deliberately small render surface for terse mutations such as start/done. */
46
+ display?: Todo[];
45
47
  truncated?: boolean;
46
48
  /** Subtask counts of previewed parents when the preview cuts the list. */
47
49
  progress?: { id: string; done: number; total: number }[];
@@ -85,11 +87,13 @@ export function registerTaskTool(
85
87
  pi.registerTool({
86
88
  name: "jar_todo",
87
89
  label: "tasks",
88
- description: "Maintain session tasks incrementally: append adds todos (or title), remove deletes id and its subtasks, replace updates only id using title/status/activeForm or exactly one todos item. Omitted replacement fields/subtasks are preserved. append with parent adds subtasks. Stable IDs are returned for every task. Legacy write (or todos without action) replaces the entire list. add/delete and start/done/open/edit/list remain supported.",
89
- promptSnippet: "Track multi-step work with jar_todo: append new tasks, replace/remove by stable id, keep one leaf in_progress, complete tasks as they finish.",
90
+ description: "Maintain session tasks: write establishes a fresh complete task plan, update changes one existing task by stable id, append adds genuinely new work, and remove deletes one task tree. start/done/open/edit remain concise status operations. Stable IDs are returned for every task.",
91
+ promptSnippet: "Track multi-step work with jar_todo: write the task plan first, update/remove existing tasks by stable id, append only when new work arrives mid-run, and keep one leaf in_progress.",
90
92
  promptGuidelines: [
91
93
  "Use jar_todo proactively, without waiting for the user, for any task with three or more distinct steps, when the user gives several tasks, or right after receiving new instructions. Skip it for single trivial requests and pure questions.",
92
- "Prefer incremental actions: append with todos adds only new tasks; replace with id updates only that task; remove with id deletes only that task tree. Use the returned stable IDs. write with todos is legacy full-list replacement and removes omitted tasks. Each todos item has content, status, and optional activeForm/subtasks.",
94
+ "Prefer write when establishing the task plan for a new request. If the existing checklist is fully completed, start the next request with a fresh write rather than appending onto old completed work.",
95
+ "Use update with a stable id to revise one existing task and remove to delete one task tree. Use append mainly when the user steers or adds genuinely new requirements while an active checklist is already in progress; do not use append as the default way to create a new plan.",
96
+ "write with todos is the complete current task plan and removes omitted tasks. Each item has content, status, and optional activeForm/subtasks.",
93
97
  "Break a large task into `subtasks` (same shape, one level deep). A parent's status follows its subtasks: it is completed when all of them are. Progress counts leaf tasks (subtasks and tasks without subtasks).",
94
98
  "Keep exactly one leaf task in_progress at a time. Mark a task in_progress before you start it and completed immediately after it is verified; do not batch completions.",
95
99
  "Only mark a task completed when it is fully done. If tests fail, work is partial or you are blocked, keep it in_progress and add a task for what must be resolved. Remove tasks that are no longer relevant."
@@ -109,7 +113,7 @@ export function registerTaskTool(
109
113
  if (!Array.isArray(params.todos)) throw new Error();
110
114
  inputs = params.todos.map(toInput);
111
115
  } catch { error = "todos must be an array of task objects with array subtasks."; }
112
- if (!["write", "append", "replace"].includes(action)) error = "todos may only be used with write, append, or replace; no tasks were changed.";
116
+ if (!["write", "append", "update"].includes(action)) error = "todos may only be used with write, append, or update; no tasks were changed.";
113
117
  }
114
118
  if (!error) switch (action) {
115
119
  case "list":
@@ -125,15 +129,15 @@ export function registerTaskTool(
125
129
  if (!error) changedId = current.all().find((item) => !before.has(item.id))?.id;
126
130
  break;
127
131
  }
128
- case "replace": {
129
- if (!params.id) { error = "replace needs a valid existing task id."; break; }
130
- if (inputs && (inputs.length !== 1 || params.title !== undefined || params.status !== undefined || params.activeForm !== undefined)) { error = "replace accepts exactly one todos item OR title/status/activeForm fields."; break; }
132
+ case "update": {
133
+ if (!params.id) { error = "update needs a valid existing task id."; break; }
134
+ if (inputs && (inputs.length !== 1 || params.title !== undefined || params.status !== undefined || params.activeForm !== undefined)) { error = "update accepts exactly one todos item OR title/status/activeForm fields."; break; }
131
135
  const replacement = inputs?.[0] ?? {
132
136
  ...(params.title !== undefined ? { title: params.title } : {}),
133
137
  ...(params.status !== undefined ? { status: params.status } : {}),
134
138
  ...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {})
135
139
  };
136
- error = current.replace(params.id, replacement);
140
+ error = current.update(params.id, replacement);
137
141
  if (!error) changedId = params.id;
138
142
  break;
139
143
  }
@@ -154,7 +158,7 @@ export function registerTaskTool(
154
158
  if (!item) error = `${action} needs a valid id from jar_todo list.`;
155
159
  else if (action === "start" && progress.total && progress.done === progress.total) error = `every subtask of ${item.id} is completed; open it or add a subtask first.`;
156
160
  else if (!current.setStatus(item.id, action === "start" ? "in_progress" : action === "done" ? "completed" : "pending")) error = "Could not save the task.";
157
- else changedId = item.id;
161
+ else changedId = action === "start" ? (current.current()?.id ?? item.id) : item.id;
158
162
  break;
159
163
  }
160
164
  case "edit":
@@ -177,6 +181,8 @@ export function registerTaskTool(
177
181
  const group = root ? items.filter((item) => item.id === root || item.parentId === root) : [];
178
182
  const mutationItems = action === "write" || action === "append" ? items : group;
179
183
  const mutationPreview = mutationItems.slice(0, 8);
184
+ const terseMutation = action === "start" || action === "done";
185
+ const terseItem = terseMutation && changedId ? items.find((item) => item.id === changedId) : undefined;
180
186
  // Keep task prose bounded while still returning every addressable id in canonical order.
181
187
  const idIndex = mutationItems.length > 8
182
188
  ? "\nTask IDs (list order): " + items.map((item, index) => `${index + 1}=${item.id}`).join(", ") + "\nUse list for all task titles."
@@ -185,7 +191,11 @@ export function registerTaskTool(
185
191
  ? `Could not ${action} tasks: ${error} (${summary(items)}).`
186
192
  : action === "list"
187
193
  ? `Tasks (${summary(items)}):\n${lines(items)}`
188
- : `Task list updated (${summary(items)}).${mutationPreview.length ? "\n" + mutationPreview.map((item) => line(items, item)).join("\n") : ""}${idIndex}\n${REMINDER}`;
194
+ : action === "start" && terseItem
195
+ ? `Started task:\n${line(items, terseItem)}`
196
+ : action === "done" && terseItem
197
+ ? `Completed task:\n${line(items, terseItem)}`
198
+ : `Task list updated (${summary(items)}).${mutationPreview.length ? "\n" + mutationPreview.map((item) => line(items, item)).join("\n") : ""}${idIndex}\n${REMINDER}`;
189
199
  // A subagent's parent mirrors the checklist from these details, so children send it whole.
190
200
  const preview = process.env.PI_JAR_CHILD ? items : items.slice(0, 8);
191
201
  const truncated = preview.length < items.length;
@@ -198,6 +208,7 @@ export function registerTaskTool(
198
208
  details: {
199
209
  action, items: preview, total: state.total, done: state.done, count: items.length,
200
210
  ids: items.map(({ id, title, parentId }) => ({ id, title, ...(parentId ? { parentId } : {}) })),
211
+ ...(terseItem ? { display: [{ ...terseItem }] } : {}),
201
212
  ...(state.current ? { current: state.current } : {}),
202
213
  ...(truncated ? { truncated: true } : {}),
203
214
  ...(progress.length ? { progress } : {}),
@@ -218,15 +229,19 @@ export function registerTaskTool(
218
229
  if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
219
230
  const details = result.details as TaskToolDetails | undefined;
220
231
  const items = details?.items ?? [];
232
+ const displayItems = details?.display ?? items;
221
233
  // Older results predate `count`, when `total` counted every item.
222
234
  const count = details?.count ?? details?.total ?? items.length;
223
235
  const totals = details?.total === undefined ? todoTotals(items) : { done: details.done, total: details.total };
224
236
  if (!count) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
225
237
  const known = new Map((details?.progress ?? []).map((value) => [value.id, value]));
226
- const shown = expanded ? items : items.slice(0, 8);
227
- let text = theme.fg("dim", `${totals.done}/${totals.total} done${details?.current ? " · now: " + details.current : ""}`);
228
- for (const item of shown) text += "\n" + todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value), known.get(item.id) ?? todoProgress(items, item.id));
229
- if (details?.truncated || shown.length < count) text += "\n" + theme.fg("dim", ` … +${Math.max(0, count - shown.length)} more · /jar tasks`);
238
+ const shown = details?.display ? displayItems : expanded ? items : items.slice(0, 8);
239
+ let text = details?.display ? "" : theme.fg("dim", `${totals.done}/${totals.total} done${details?.current ? " · now: " + details.current : ""}`);
240
+ for (const item of shown) {
241
+ const row = todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value), known.get(item.id) ?? todoProgress(items, item.id));
242
+ text += (text ? "\n" : "") + row;
243
+ }
244
+ if (!details?.display && (details?.truncated || shown.length < count)) text += "\n" + theme.fg("dim", ` … +${Math.max(0, count - shown.length)} more · /jar tasks`);
230
245
  return new Text(text, 0, 0);
231
246
  }
232
247
  });
package/src/tasks.ts CHANGED
@@ -11,7 +11,7 @@ export type TodoStatus = (typeof TODO_STATUSES)[number];
11
11
  export interface Todo { id: string; title: string; done: boolean; status: TodoStatus; activeForm?: string; details?: string; parentId?: string }
12
12
  /** A parent's own `status` is ignored when it has subtasks. */
13
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">>;
14
+ export type TodoUpdate = Partial<Omit<TodoInput, "id">>;
15
15
  /** Bounds the whole list: parents plus subtasks. */
16
16
  export const MAX_TODOS = 50;
17
17
 
@@ -312,12 +312,12 @@ export class TodoStore {
312
312
  } else next.push(...inputs);
313
313
  return this.write(next);
314
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.";
315
+ /** Update only the addressed task. Omitted fields and subtasks are preserved. */
316
+ update(id: string, replacement: TodoUpdate): string | undefined {
317
+ if (!this.items.has(id)) return "update needs a valid existing task id.";
318
+ if (!replacement || typeof replacement !== "object" || Array.isArray(replacement)) return "update needs task fields.";
319
+ if ("id" in replacement && replacement.id !== id) return "Update id must match the addressed task.";
320
+ if (!Object.keys(replacement).some((key) => ["title", "status", "activeForm", "subtasks"].includes(key))) return "update needs at least one task field.";
321
321
  const next = this.snapshot();
322
322
  const target = next.flatMap((item) => [item, ...(item.subtasks ?? [])]).find((item) => item.id === id)!;
323
323
  if (this.items.get(id)?.parentId && replacement.subtasks?.length) return "Subtasks cannot have their own subtasks; nest only one level.";