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/side-model.ts CHANGED
@@ -28,33 +28,53 @@ export interface SideAnswer { text: string; model: string }
28
28
 
29
29
  /**
30
30
  * Ask the model assigned to `role` (or the current model) a one-shot question outside the
31
- * conversation. Throws on a missing model, provider error or empty answer.
31
+ * conversation. Tries configured fallbacks in order on failure; cancellation never retries.
32
32
  */
33
33
  export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, role: string,
34
34
  systemPrompt: string, prompt: string, signal?: AbortSignal): Promise<SideAnswer> {
35
- const assigned = roles.resolve(role);
36
- const model = assigned ? ctx.modelRegistry.find(assigned.provider, assigned.model) : ctx.model;
37
- if (!model) throw new Error(assigned ? `model not found for role ${role}: ${assigned.provider}/${assigned.model}` : "no model selected");
38
- const thinking: ThinkingLevel | undefined = assigned?.thinking;
39
- const reasoning = thinking && thinking !== "off" ? thinking : undefined;
40
- const result = await ctx.modelRegistry.streamSimple(model,
41
- { systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
42
- { ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
43
- const name = `${model.provider}/${model.id}`;
44
- const cost = result.usage?.cost?.total;
45
- usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
46
- cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
47
- if (result.stopReason === "error" || result.stopReason === "aborted") throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
48
- const MAX_ANSWER_CHARS = 32 * 1024;
49
- let text = "";
50
- for (const part of result.content) {
51
- if (part.type !== "text") continue;
52
- const value = (part as { text: string }).text;
53
- const room = MAX_ANSWER_CHARS - text.length;
54
- if (room <= 0) break;
55
- text += value.slice(0, room);
35
+ const fallbacks = roles.fallbackSpecs?.(role) ?? [];
36
+ const failures: string[] = [];
37
+ const tried = new Set<string>();
38
+ for (const spec of [undefined, ...fallbacks]) {
39
+ signal?.throwIfAborted();
40
+ let name = spec ?? role;
41
+ let aborted = false;
42
+ try {
43
+ const assigned = spec === undefined ? roles.resolve(role) : roles.resolveSpec(spec);
44
+ const model = assigned ? ctx.modelRegistry.find(assigned.provider, assigned.model) : ctx.model;
45
+ if (!model) throw new Error(assigned ? `model not found for role ${role}: ${assigned.provider}/${assigned.model}` : "no model selected");
46
+ name = `${model.provider}/${model.id}`;
47
+ const key = name + ":" + (assigned?.thinking ?? "off");
48
+ if (tried.has(key)) continue;
49
+ tried.add(key);
50
+ const thinking: ThinkingLevel | undefined = assigned?.thinking;
51
+ const reasoning = thinking && thinking !== "off" ? thinking : undefined;
52
+ const result = await ctx.modelRegistry.streamSimple(model,
53
+ { systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
54
+ { ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
55
+ const cost = result.usage?.cost?.total;
56
+ usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
57
+ cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
58
+ signal?.throwIfAborted();
59
+ aborted = result.stopReason === "aborted";
60
+ if (result.stopReason === "error" || aborted) throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
61
+ const MAX_ANSWER_CHARS = 32 * 1024;
62
+ let text = "";
63
+ for (const part of result.content) {
64
+ if (part.type !== "text") continue;
65
+ const value = (part as { text: string }).text;
66
+ const room = MAX_ANSWER_CHARS - text.length;
67
+ if (room <= 0) break;
68
+ text += value.slice(0, room);
69
+ }
70
+ text = text.trim();
71
+ if (!text) throw new Error(`${name} returned an empty answer`);
72
+ return { text, model: name };
73
+ } catch (error) {
74
+ signal?.throwIfAborted();
75
+ if (aborted || (error instanceof Error && error.name === "AbortError") || !fallbacks.length) throw error;
76
+ failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
77
+ }
56
78
  }
57
- text = text.trim();
58
- if (!text) throw new Error(`${name} returned an empty answer`);
59
- return { text, model: name };
79
+ throw new Error(`All models for role ${role} failed:\n${failures.join("\n")}`);
60
80
  }
package/src/split-view.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { RowCache } from "./row-cache.ts";
2
3
 
3
4
  export interface SplitTheme { fg(color: string, text: string): string }
4
5
 
@@ -15,9 +16,14 @@ export interface SplitLayout {
15
16
  footerTop: number;
16
17
  }
17
18
 
18
- const pad = (text: string, width: number) => {
19
- const value = truncateToWidth(text, Math.max(0, width));
20
- return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
19
+ /** Padded pane rows: scrolling or one live row re-pads only what changed. */
20
+ const padded = { left: new RowCache(), right: new RowCache(), footer: new RowCache() };
21
+
22
+ /** The last frame: overlays repaint on every Pi render (including each streamed token) with unchanged rows. */
23
+ let previous: { key: string; left: string[]; right: string[]; footer: string[]; lines: string[]; layout: SplitLayout } | undefined;
24
+ const sameRows = (a: readonly string[], b: readonly string[], rows: number) => {
25
+ for (let row = 0; row < rows; row++) if ((a[row] ?? "") !== (b[row] ?? "")) return false;
26
+ return true;
21
27
  };
22
28
 
23
29
  /** Sidebar width for a split view, or 0 when the terminal is too narrow for two panes. */
@@ -34,25 +40,36 @@ export function splitFrame(theme: SplitTheme, width: number, title: string, left
34
40
  footer: readonly string[], rows: number, leftWidth = sidebarWidth(width)): { lines: string[]; layout: SplitLayout } {
35
41
  const w = Math.max(12, width);
36
42
  const dim = (text: string) => theme.fg("dim", text);
37
- const heading = " " + title + " ";
38
- const top = theme.fg("accent", "╭─" + truncateToWidth(heading, Math.max(0, w - 6)) + "─".repeat(Math.max(0, w - 4 - visibleWidth(truncateToWidth(heading, Math.max(0, w - 6))))) + "×╮");
43
+ const key = `${w}\0${title}\0${rows}\0${leftWidth}\0${dim("│")}${theme.fg("accent", "╭")}`;
44
+ if (previous?.key === key && footer.length === previous.footer.length && sameRows(footer, previous.footer, footer.length)
45
+ && sameRows(left, previous.left, rows) && sameRows(right, previous.right, rows)) {
46
+ return { lines: previous.lines.slice(), layout: { ...previous.layout } };
47
+ }
48
+ const heading = truncateToWidth(" " + title + " ", Math.max(0, w - 6));
49
+ const top = theme.fg("accent", "╭─" + heading + "─".repeat(Math.max(0, w - 4 - visibleWidth(heading))) + "×╮");
39
50
  const lines = [top];
40
51
  const split = leftWidth > 0;
41
52
  const bodyWidth = split ? w - leftWidth - 6 : w - 4;
53
+ padded.left.begin(String(leftWidth));
54
+ padded.right.begin(String(bodyWidth));
55
+ padded.footer.begin(String(w - 4));
56
+ // truncateToWidth pads in the pass that measures, so each new row is scanned once.
57
+ const pad = (cache: RowCache, text: string, cells: number) => cache.get(text, (value) => truncateToWidth(value, Math.max(0, cells), "...", true));
42
58
  for (let row = 0; row < rows; row++) {
43
- const body = pad(right[row] ?? "", bodyWidth);
59
+ const body = pad(padded.right, right[row] ?? "", bodyWidth);
44
60
  lines.push(split
45
- ? dim("│ ") + pad(left[row] ?? "", leftWidth) + dim("│ ") + body + dim(" │")
61
+ ? dim("│ ") + pad(padded.left, left[row] ?? "", leftWidth) + dim("│ ") + body + dim(" │")
46
62
  : dim("│ ") + body + dim(" │"));
47
63
  }
48
64
  lines.push(dim("├" + (split ? "─".repeat(leftWidth + 1) + "┴" + "─".repeat(Math.max(0, w - leftWidth - 4)) : "─".repeat(w - 2)) + "┤"));
49
65
  const footerTop = lines.length;
50
- for (const line of footer) lines.push(dim("│ ") + pad(line, w - 4) + dim(" │"));
66
+ for (const line of footer) lines.push(dim("│ ") + pad(padded.footer, line, w - 4) + dim(" │"));
51
67
  lines.push(dim("╰" + "─".repeat(w - 2) + "╯"));
52
- return {
53
- lines: lines.map((line) => truncateToWidth(line, w)),
54
- layout: { top: 1, rows, leftWidth: split ? leftWidth : 0, bodyX: split ? leftWidth + 4 : 2, footerTop }
55
- };
68
+ // Every row is assembled from exact-width parts; only a sidebar too wide for the frame can overflow.
69
+ const fitted = bodyWidth < 0 ? lines.map((line) => truncateToWidth(line, w)) : lines;
70
+ const layout = { top: 1, rows, leftWidth: split ? leftWidth : 0, bodyX: split ? leftWidth + 4 : 2, footerTop };
71
+ previous = { key, left: left.slice(0, rows), right: right.slice(0, rows), footer: footer.slice(), lines: fitted, layout };
72
+ return { lines: fitted.slice(), layout: { ...layout } };
56
73
  }
57
74
 
58
75
  /** Available content rows for a full-height overlay with `chrome` fixed rows. */
package/src/status.ts CHANGED
@@ -62,6 +62,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
62
62
  const key = cleanText(rawKey, 48);
63
63
  if (!key || typeof rawValue !== "string") continue;
64
64
  if (rawKey.startsWith("pi-jar.quota.")) continue;
65
+ // SoL-Pi already announces each saving in its own notification; reprinting it here duplicates it.
66
+ if (rawKey.startsWith("sol-pi")) continue;
65
67
  if (!rawKey.startsWith(ROLE_PREFIX)) {
66
68
  const value = cleanText(rawValue);
67
69
  // pi-jar's own indicators (such as `role:default`) are already self-describing.
package/src/task-tool.ts CHANGED
@@ -1,53 +1,78 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
- import type { Todo } from "./tasks.ts";
5
- import { TODO_STATUSES, TodoStore, todoMark } from "./tasks.ts";
4
+ import type { Todo, TodoInput, TodoStatus } from "./tasks.ts";
5
+ import { TODO_STATUSES, TodoStore, todoMark, todoProgress, todoTotals } from "./tasks.ts";
6
6
 
7
- const ACTIONS = ["write", "list", "add", "start", "done", "open", "edit", "delete"] as const;
8
- const Action = Type.Unsafe<(typeof ACTIONS)[number]>({ type: "string", enum: ACTIONS as any });
9
- const Status = Type.Unsafe<(typeof TODO_STATUSES)[number]>({ type: "string", enum: TODO_STATUSES as any });
7
+ const ACTIONS = ["write", "list", "append", "remove", "replace", "add", "start", "done", "open", "edit", "delete"] as const;
8
+ const Action = Type.Unsafe<(typeof ACTIONS)[number]>({ type: "string", enum: [...ACTIONS] });
9
+ const Status = Type.Unsafe<(typeof TODO_STATUSES)[number]>({ type: "string", enum: [...TODO_STATUSES] });
10
+ const Content = Type.String({ description: "Imperative task title, e.g. \"Run the tests\"." });
11
+ const ActiveForm = Type.Optional(Type.String({ description: "Present-continuous form shown while it runs, e.g. \"Running the tests\"." }));
10
12
 
11
13
  const Parameters = Type.Object({
12
14
  todos: Type.Optional(Type.Array(Type.Object({
13
- content: Type.String({ description: "Imperative task title, e.g. \"Run the tests\"." }),
15
+ id: Type.Optional(Type.String({ description: "Existing stable task id for legacy write; omitted ids match by title." })),
16
+ content: Content,
14
17
  status: Status,
15
- activeForm: Type.Optional(Type.String({ description: "Present-continuous form shown while it runs, e.g. \"Running the tests\"." }))
16
- }), { description: "The complete, updated task list. Replaces the current list." })),
18
+ activeForm: ActiveForm,
19
+ subtasks: Type.Optional(Type.Array(Type.Object({ id: Type.Optional(Type.String()), content: Content, status: Status, activeForm: ActiveForm }), {
20
+ description: "Optional steps of this task (one level deep). The task's status then follows its subtasks."
21
+ }))
22
+ }), { description: "For append: new tasks only. For replace: exactly one replacement task. With write (or no action): the complete list, replacing it." })),
17
23
  action: Type.Optional(Action),
18
- id: Type.Optional(Type.String({ description: "Task id for start/done/open/edit/delete." })),
19
- title: Type.Optional(Type.String({ description: "Concise actionable task title for add/edit." })),
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." })),
26
+ status: Type.Optional(Status),
27
+ activeForm: ActiveForm,
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." })),
20
29
  details: Type.Optional(Type.String({ description: "Optional short task context for a new task." }))
21
30
  });
22
31
 
23
32
  interface TaskToolDetails {
24
33
  action: string;
25
- /** Bounded preview only; the canonical list lives in TodoStore. */
34
+ /** Bounded preview (the full list in a subagent); the canonical list lives in TodoStore. */
26
35
  items: Todo[];
36
+ /** Leaf tasks: subtasks plus top-level tasks without subtasks. */
27
37
  total: number;
28
38
  done: number;
39
+ /** Every item, parents included. */
40
+ count?: number;
29
41
  current?: string;
30
42
  changed?: string;
43
+ /** Full stable id index, including items outside the bounded preview. */
44
+ ids?: { id: string; title: string; parentId?: string }[];
31
45
  truncated?: boolean;
46
+ /** Subtask counts of previewed parents when the preview cuts the list. */
47
+ progress?: { id: string; done: number; total: number }[];
32
48
  }
33
49
 
50
+ type TodoParam = { id?: string; content: string; status: TodoStatus; activeForm?: string; subtasks?: readonly TodoParam[] };
51
+ /** Deeper nesting is passed through so the store rejects it with a clear message. */
52
+ const toInput = (item: TodoParam): TodoInput => ({
53
+ ...(item.id !== undefined ? { id: item.id } : {}),
54
+ title: item.content, status: item.status,
55
+ ...(item.activeForm !== undefined ? { activeForm: item.activeForm } : {}),
56
+ ...(item.subtasks !== undefined ? { subtasks: item.subtasks.map(toInput) } : {})
57
+ });
58
+
34
59
  const stats = (items: readonly Todo[]) => {
35
- let done = 0;
36
- let current: Todo | undefined;
37
- for (const item of items) {
38
- if (item.done) done++;
39
- if (!current && item.status === "in_progress") current = item;
40
- }
41
- return { total: items.length, done, current };
60
+ const parents = new Set<string>();
61
+ for (const item of items) if (item.parentId) parents.add(item.parentId);
62
+ const current = items.find((item) => item.status === "in_progress" && !parents.has(item.id));
63
+ const parent = current?.parentId ? items.find((item) => item.id === current.parentId) : undefined;
64
+ return { ...todoTotals(items), current: current && (parent ? parent.title + " › " : "") + current.title };
42
65
  };
43
66
  const summary = (items: readonly Todo[]) => {
44
67
  const value = stats(items);
45
- return `${value.done}/${value.total} done` + (value.current ? ` · now: ${value.current.title}` : "");
68
+ return `${value.done}/${value.total} done` + (value.current ? ` · now: ${value.current}` : "");
46
69
  };
47
70
 
48
- const lines = (items: readonly Todo[]) => items.length
49
- ? items.map((item) => `${todoMark(item)} [${item.status}] ${item.title} (${item.id})`).join("\n")
50
- : "No tracked tasks.";
71
+ const line = (items: readonly Todo[], item: Todo) => {
72
+ const progress = todoProgress(items, item.id);
73
+ return `${item.parentId ? " " : ""}${todoMark(item)} [${item.status}] ${item.title}${progress.total ? ` (${progress.done}/${progress.total} done)` : ""} (${item.id})`;
74
+ };
75
+ const lines = (items: readonly Todo[]) => items.length ? items.map((item) => line(items, item)).join("\n") : "No tracked tasks.";
51
76
 
52
77
  const REMINDER = "Keep using jar_todo to track progress: mark the next task in_progress before starting it and completed as soon as it is verified.";
53
78
 
@@ -60,74 +85,129 @@ export function registerTaskTool(
60
85
  pi.registerTool({
61
86
  name: "jar_todo",
62
87
  label: "tasks",
63
- description: "Maintain pi-jar's session task list, shown live to the user. Send `todos` with the complete updated list (each with content, status pending|in_progress|completed, and activeForm). Single-task actions (start, done, open, add, edit, delete, list) are also available.",
64
- promptSnippet: "Track multi-step work with jar_todo: write the full list, keep exactly one task in_progress, complete tasks as they finish.",
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.",
65
90
  promptGuidelines: [
66
91
  "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.",
67
- "Prefer jar_todo with `todos` (the full updated list). Each item has `content` (imperative, e.g. \"Run tests\"), `status`, and `activeForm` (present continuous, e.g. \"Running tests\"), which the user sees while it runs.",
68
- "Keep exactly one 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.",
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.",
93
+ "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
+ "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.",
69
95
  "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."
70
96
  ],
71
97
  parameters: Parameters,
72
98
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
73
99
  const current = store();
74
- const action = params.todos ? "write" : params.action ?? "list";
100
+ const action = params.action ?? (params.todos !== undefined ? "write" : "list");
75
101
  if (!current) {
76
102
  return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [], total: 0, done: 0 } satisfies TaskToolDetails };
77
103
  }
78
104
  let changedId: string | undefined;
79
105
  let error: string | undefined;
80
- switch (action) {
106
+ let inputs: TodoInput[] | undefined;
107
+ if (params.todos !== undefined) {
108
+ try {
109
+ if (!Array.isArray(params.todos)) throw new Error();
110
+ inputs = params.todos.map(toInput);
111
+ } 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.";
113
+ }
114
+ if (!error) switch (action) {
81
115
  case "list":
82
116
  break;
83
117
  case "write":
84
- if (!params.todos) { error = "write needs `todos`: the complete updated list."; break; }
85
- error = current.write(params.todos.map((item) => ({ title: item.content, status: item.status, ...(item.activeForm ? { activeForm: item.activeForm } : {}) })));
118
+ if (!inputs) { error = "write needs `todos`: the complete updated list."; break; }
119
+ error = current.write(inputs);
120
+ break;
121
+ case "append": {
122
+ const before = new Set(current.all().map((item) => item.id));
123
+ if (inputs && params.title !== undefined) { error = "append accepts either todos or title, not both."; break; }
124
+ error = current.appendTodos(inputs ?? [{ title: params.title ?? "", status: params.status ?? "pending", activeForm: params.activeForm, details: params.details }], params.parent);
125
+ if (!error) changedId = current.all().find((item) => !before.has(item.id))?.id;
86
126
  break;
127
+ }
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; }
131
+ const replacement = inputs?.[0] ?? {
132
+ ...(params.title !== undefined ? { title: params.title } : {}),
133
+ ...(params.status !== undefined ? { status: params.status } : {}),
134
+ ...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {})
135
+ };
136
+ error = current.replace(params.id, replacement);
137
+ if (!error) changedId = params.id;
138
+ break;
139
+ }
87
140
  case "add": {
88
- const item = params.title?.trim() ? current.add(params.title, params.details) : undefined;
89
- if (!item) error = "add needs a short title.";
141
+ const parent = params.parent?.trim() || undefined;
142
+ const title = params.title ?? "";
143
+ error = current.addError(title, parent);
144
+ const item = error ? undefined : current.add(title, params.details, parent);
145
+ if (!error && !item) error = "Could not save the task.";
90
146
  changedId = item?.id;
91
147
  break;
92
148
  }
93
149
  case "start":
94
150
  case "done":
95
- case "open":
96
- if (!params.id || !current.setStatus(params.id, action === "start" ? "in_progress" : action === "done" ? "completed" : "pending")) error = `${action} needs a valid id from jar_todo list.`;
97
- else changedId = params.id;
151
+ case "open": {
152
+ const item = params.id ? current.get(params.id) : undefined;
153
+ const progress = item ? todoProgress(current.all(), item.id) : { done: 0, total: 0 };
154
+ if (!item) error = `${action} needs a valid id from jar_todo list.`;
155
+ 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
+ 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;
98
158
  break;
159
+ }
99
160
  case "edit":
100
161
  if (!params.id || !params.title?.trim() || !current.edit(params.id, params.title)) error = "edit needs a valid id and a title.";
101
162
  else changedId = params.id;
102
163
  break;
164
+ case "remove":
103
165
  case "delete":
104
- if (!params.id || !current.delete(params.id)) error = "delete needs a valid id from jar_todo list.";
166
+ if (!params.id || !current.delete(params.id)) error = `${action} needs a valid id from jar_todo list.`;
105
167
  else changedId = params.id;
106
168
  break;
169
+ default: error = "Unknown task action; no tasks were changed.";
107
170
  }
108
171
  if (action !== "list" && !error) changed(ctx);
109
172
  const items = current.all();
110
173
  const state = stats(items);
174
+ // The changed task in context: its whole parent group, subtasks indented.
111
175
  const changedItem = changedId ? items.find((item) => item.id === changedId) : undefined;
176
+ const root = changedItem?.parentId ?? changedItem?.id;
177
+ const group = root ? items.filter((item) => item.id === root || item.parentId === root) : [];
178
+ const mutationItems = action === "write" || action === "append" ? items : group;
179
+ const mutationPreview = mutationItems.slice(0, 8);
180
+ // Keep task prose bounded while still returning every addressable id in canonical order.
181
+ const idIndex = mutationItems.length > 8
182
+ ? "\nTask IDs (list order): " + items.map((item, index) => `${index + 1}=${item.id}`).join(", ") + "\nUse list for all task titles."
183
+ : "";
112
184
  const message = error
113
185
  ? `Could not ${action} tasks: ${error} (${summary(items)}).`
114
186
  : action === "list"
115
187
  ? `Tasks (${summary(items)}):\n${lines(items)}`
116
- : `Task list updated (${summary(items)}).${changedItem ? `\n${todoMark(changedItem)} [${changedItem.status}] ${changedItem.title} (${changedItem.id})` : ""}\n${REMINDER}`;
117
- const preview = items.slice(0, 8);
188
+ : `Task list updated (${summary(items)}).${mutationPreview.length ? "\n" + mutationPreview.map((item) => line(items, item)).join("\n") : ""}${idIndex}\n${REMINDER}`;
189
+ // A subagent's parent mirrors the checklist from these details, so children send it whole.
190
+ const preview = process.env.PI_JAR_CHILD ? items : items.slice(0, 8);
191
+ const truncated = preview.length < items.length;
192
+ const progress = truncated ? preview.flatMap((item) => {
193
+ const value = todoProgress(items, item.id);
194
+ return value.total ? [{ id: item.id, ...value }] : [];
195
+ }) : [];
118
196
  return {
119
197
  content: [{ type: "text", text: message }],
120
198
  details: {
121
- action, items: preview, total: state.total, done: state.done,
122
- ...(state.current ? { current: state.current.title } : {}),
123
- ...(preview.length < items.length ? { truncated: true } : {}),
199
+ action, items: preview, total: state.total, done: state.done, count: items.length,
200
+ ids: items.map(({ id, title, parentId }) => ({ id, title, ...(parentId ? { parentId } : {}) })),
201
+ ...(state.current ? { current: state.current } : {}),
202
+ ...(truncated ? { truncated: true } : {}),
203
+ ...(progress.length ? { progress } : {}),
124
204
  ...(changedId ? { changed: changedId } : {})
125
205
  } satisfies TaskToolDetails
126
206
  };
127
207
  },
128
208
  renderCall(args, theme) {
129
209
  let text = theme.fg("toolTitle", theme.bold("tasks"));
130
- if (args.todos) text += " " + theme.fg("accent", `update · ${args.todos.length} item${args.todos.length === 1 ? "" : "s"}`);
210
+ if (args.todos) text += " " + theme.fg("accent", `${args.action ?? "write"} · ${args.todos.length} item${args.todos.length === 1 ? "" : "s"}`);
131
211
  else {
132
212
  text += " " + theme.fg("accent", args.action ?? "list");
133
213
  if (args.title) text += " " + theme.fg("muted", args.title);
@@ -138,22 +218,29 @@ export function registerTaskTool(
138
218
  if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
139
219
  const details = result.details as TaskToolDetails | undefined;
140
220
  const items = details?.items ?? [];
141
- const total = details?.total ?? items.length;
142
- const done = details?.done ?? items.filter((item) => item.done).length;
143
- if (!total) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
221
+ // Older results predate `count`, when `total` counted every item.
222
+ const count = details?.count ?? details?.total ?? items.length;
223
+ const totals = details?.total === undefined ? todoTotals(items) : { done: details.done, total: details.total };
224
+ if (!count) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
225
+ const known = new Map((details?.progress ?? []).map((value) => [value.id, value]));
144
226
  const shown = expanded ? items : items.slice(0, 8);
145
- let text = theme.fg("dim", `${done}/${total} done${details?.current ? " · now: " + details.current : ""}`);
146
- for (const item of shown) text += "\n" + todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value));
147
- if (details?.truncated || shown.length < total) text += "\n" + theme.fg("dim", ` … +${Math.max(0, total - shown.length)} more · /jar tasks`);
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`);
148
230
  return new Text(text, 0, 0);
149
231
  }
150
232
  });
151
233
  }
152
234
 
153
235
  type TodoColor = "accent" | "muted" | "dim" | "success";
154
- /** One checklist row: completed rows are struck through, the running one is bold. */
155
- export function todoRow(item: Todo, fg: (color: TodoColor, text: string) => string, bold: (text: string) => string = (text) => text): string {
156
- if (item.status === "completed") return fg("success", " ✔ ") + fg("dim", "\x1b[9m" + item.title + "\x1b[29m");
157
- if (item.status === "in_progress") return fg("accent", " ◼ ") + fg("accent", bold(item.title));
158
- return fg("muted", " ☐ " + item.title);
236
+ /**
237
+ * One checklist row: completed rows are struck through, the running one is bold. Subtasks are
238
+ * indented; pass a parent's subtask `progress` to show its `(done/total)`.
239
+ */
240
+ export function todoRow(item: Todo, fg: (color: TodoColor, text: string) => string, bold: (text: string) => string = (text) => text, progress?: { done: number; total: number }): string {
241
+ const indent = item.parentId ? " " : " ";
242
+ const suffix = progress?.total ? fg("dim", ` (${progress.done}/${progress.total})`) : "";
243
+ if (item.status === "completed") return fg("success", indent + "✔ ") + fg("dim", "\x1b[9m" + item.title + "\x1b[29m") + suffix;
244
+ if (item.status === "in_progress") return fg("accent", indent + "◼ ") + fg("accent", bold(item.title)) + suffix;
245
+ return fg("muted", indent + "☐ " + item.title) + suffix;
159
246
  }
package/src/tasks-ui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { promptChoice, promptText, todoView, type TodoFilter } from "./dialogs.ts";
3
- import { TodoStore } from "./tasks.ts";
3
+ import { TodoStore, todoProgress } from "./tasks.ts";
4
4
 
5
5
  const help = "Usage: /jar tasks [list|add TITLE|done ID|open ID|edit ID TITLE|delete ID]";
6
6
 
@@ -11,7 +11,15 @@ export async function manageTasks(args: string, ctx: ExtensionContext, store: To
11
11
  };
12
12
  const list = () => {
13
13
  const items = store.all();
14
- notify(items.length ? items.map((item) => `${item.done ? "[x]" : "[ ]"} ${item.title} (${item.id})`).join("\n") : "pi-jar to-do: empty (separate from other task managers)");
14
+ notify(items.length ? items.map((item) => {
15
+ const progress = todoProgress(items, item.id);
16
+ return `${item.parentId ? " " : ""}${item.done ? "[x]" : "[ ]"} ${item.title}${progress.total ? ` (${progress.done}/${progress.total})` : ""} (${item.id})`;
17
+ }).join("\n") : "pi-jar to-do: empty (separate from other task managers)");
18
+ };
19
+ /** Deleting a parent also deletes its subtasks, so say so before confirming. */
20
+ const deleteLabel = (id: string, title: string) => {
21
+ const count = todoProgress(store.all(), id).total;
22
+ return count ? `${title} (and ${count} subtask${count === 1 ? "" : "s"})` : title;
15
23
  };
16
24
  if (!args && ctx.hasUI && ctx.mode === "tui") {
17
25
  const filter: { value: TodoFilter } = { value: "all" };
@@ -29,7 +37,7 @@ export async function manageTasks(args: string, ctx: ExtensionContext, store: To
29
37
  if (title) success = store.edit(action.id, title);
30
38
  } else if (action.id && action.kind === "delete") {
31
39
  const item = store.get(action.id);
32
- const choice = item && await promptChoice(ctx, "Delete to-do?", item.title, ["Delete item", "Keep item"], 1);
40
+ const choice = item && await promptChoice(ctx, "Delete to-do?", deleteLabel(item.id, item.title), ["Delete item", "Keep item"], 1);
33
41
  if (choice === 0) success = store.delete(action.id);
34
42
  }
35
43
  if (success) changed();
@@ -47,8 +55,10 @@ export async function manageTasks(args: string, ctx: ExtensionContext, store: To
47
55
  }
48
56
  case "done":
49
57
  case "open": {
58
+ // On a parent, done completes and open reopens every subtask.
50
59
  const item = id && store.get(id);
51
- if (item && item.done !== (verb === "done") && store.toggle(id)) { changed(); notify("Updated pi-jar to-do"); }
60
+ const already = item && (verb === "done" ? item.done : item.status === "pending");
61
+ if (item && !already && store.setStatus(id, verb === "done" ? "completed" : "pending")) { changed(); notify("Updated pi-jar to-do"); }
52
62
  else notify("To-do not found or already in that state", "error");
53
63
  return;
54
64
  }
@@ -61,7 +71,7 @@ export async function manageTasks(args: string, ctx: ExtensionContext, store: To
61
71
  case "delete": {
62
72
  if (!id || !store.get(id)) { notify("To-do not found", "error"); return; }
63
73
  if (ctx.hasUI && ctx.mode === "tui") {
64
- const choice = await promptChoice(ctx, "Delete pi-jar to-do?", store.get(id)!.title, ["Delete item", "Keep item"], 1);
74
+ const choice = await promptChoice(ctx, "Delete pi-jar to-do?", deleteLabel(id, store.get(id)!.title), ["Delete item", "Keep item"], 1);
65
75
  if (choice !== 0) return;
66
76
  }
67
77
  // Headless deletion is explicit and requires the full ID; TUI deletion asks for confirmation.