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/README.md +46 -16
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +7 -3
- package/extensions/index.ts +194 -71
- package/package.json +1 -1
- package/src/activity-view.ts +525 -0
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +69 -21
- package/src/delegate.ts +492 -51
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +131 -26
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/icons.ts +57 -0
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/plan.ts +1 -0
- package/src/prompt-search.ts +20 -10
- package/src/row-cache.ts +26 -0
- package/src/session-gallery.ts +8 -3
- package/src/session-ui.ts +112 -42
- package/src/settings-ui.ts +10 -11
- package/src/settings.ts +5 -1
- package/src/shells.ts +8 -84
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -0
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +47 -14
- package/src/working.ts +3 -1
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.
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
9
|
-
const Status = Type.Unsafe<(typeof TODO_STATUSES)[number]>({ type: "string", enum: TODO_STATUSES
|
|
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
|
-
|
|
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:
|
|
16
|
-
|
|
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: "
|
|
19
|
-
title: Type.Optional(Type.String({ description: "
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
68
|
+
return `${value.done}/${value.total} done` + (value.current ? ` · now: ${value.current}` : "");
|
|
46
69
|
};
|
|
47
70
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
: "
|
|
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
|
|
64
|
-
promptSnippet: "Track multi-step work with jar_todo:
|
|
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
|
|
68
|
-
"
|
|
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" :
|
|
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
|
-
|
|
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 (!
|
|
85
|
-
error = current.write(
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 =
|
|
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)}).${
|
|
117
|
-
|
|
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
|
-
...(
|
|
123
|
-
...(
|
|
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",
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
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 <
|
|
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
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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) =>
|
|
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
|
-
|
|
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.
|