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.
- package/README.md +34 -18
- package/docs/WORKFLOWS.md +32 -10
- package/extensions/index.ts +74 -13
- package/package.json +1 -1
- package/src/activity-view.ts +12 -5
- package/src/ask-tool.ts +12 -7
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +826 -124
- package/src/democracy.ts +192 -0
- package/src/discussion.ts +153 -0
- package/src/footer.ts +4 -3
- package/src/goal-loop.ts +8 -1
- package/src/model-roles.ts +58 -29
- package/src/roles-ui.ts +18 -10
- package/src/settings-ui.ts +41 -15
- package/src/settings.ts +9 -1
- package/src/status.ts +2 -1
- package/src/task-tool.ts +33 -18
- package/src/tasks.ts +7 -7
package/src/settings-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
53
|
-
|
|
60
|
+
choosing = "goalRounds";
|
|
61
|
+
selected = Math.max(0, GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never));
|
|
54
62
|
}
|
|
55
|
-
else if (index === 3)
|
|
56
|
-
|
|
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") {
|
|
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 =
|
|
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, "
|
|
131
|
-
row(4, "Advisor
|
|
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",
|
|
145
|
-
|
|
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",
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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
|
|
25
|
-
title: Type.Optional(Type.String({ description: "Task title for append/add/edit/
|
|
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
|
|
89
|
-
promptSnippet: "Track multi-step work with jar_todo:
|
|
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
|
|
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", "
|
|
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 "
|
|
129
|
-
if (!params.id) { error = "
|
|
130
|
-
if (inputs && (inputs.length !== 1 || params.title !== undefined || params.status !== undefined || params.activeForm !== undefined)) { error = "
|
|
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.
|
|
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
|
-
:
|
|
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)
|
|
229
|
-
|
|
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
|
|
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
|
-
/**
|
|
316
|
-
|
|
317
|
-
if (!this.items.has(id)) return "
|
|
318
|
-
if (!replacement || typeof replacement !== "object" || Array.isArray(replacement)) return "
|
|
319
|
-
if ("id" in replacement && replacement.id !== id) return "
|
|
320
|
-
if (!Object.keys(replacement).some((key) => ["title", "status", "activeForm", "subtasks"].includes(key))) return "
|
|
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.";
|