pi-jar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -0
  3. package/docs/DESIGN.md +85 -0
  4. package/docs/DEVELOPMENT.md +95 -0
  5. package/docs/FLAME.md +54 -0
  6. package/docs/INTEGRATIONS.md +82 -0
  7. package/docs/PLAN.md +68 -0
  8. package/docs/SHOWCASE.md +22 -0
  9. package/docs/WORKFLOWS.md +141 -0
  10. package/extensions/index.ts +773 -0
  11. package/package.json +62 -0
  12. package/src/accent.ts +36 -0
  13. package/src/advisor.ts +194 -0
  14. package/src/animations.ts +23 -0
  15. package/src/ask-tool.ts +185 -0
  16. package/src/attachments.ts +79 -0
  17. package/src/changes.ts +146 -0
  18. package/src/commit.ts +61 -0
  19. package/src/compact-tools.ts +129 -0
  20. package/src/composer.ts +404 -0
  21. package/src/context-view.ts +141 -0
  22. package/src/delegate.ts +208 -0
  23. package/src/dialogs.ts +127 -0
  24. package/src/diff-view.ts +160 -0
  25. package/src/flame.ts +245 -0
  26. package/src/footer-settings.ts +32 -0
  27. package/src/footer.ts +147 -0
  28. package/src/goal-loop.ts +313 -0
  29. package/src/goals.ts +114 -0
  30. package/src/history-ui.ts +136 -0
  31. package/src/history.ts +168 -0
  32. package/src/info-panels.ts +75 -0
  33. package/src/mascot.ts +103 -0
  34. package/src/model-roles.ts +332 -0
  35. package/src/panel.ts +90 -0
  36. package/src/plan-utils.ts +191 -0
  37. package/src/plan-view.ts +149 -0
  38. package/src/plan.ts +460 -0
  39. package/src/prompt-search.ts +83 -0
  40. package/src/quota.ts +134 -0
  41. package/src/roles-ui.ts +190 -0
  42. package/src/roles.ts +25 -0
  43. package/src/session-gallery.ts +52 -0
  44. package/src/session-ui.ts +76 -0
  45. package/src/settings-ui.ts +154 -0
  46. package/src/settings.ts +96 -0
  47. package/src/shells.ts +319 -0
  48. package/src/side-model.ts +48 -0
  49. package/src/split-view.ts +74 -0
  50. package/src/status.ts +71 -0
  51. package/src/suggest.ts +89 -0
  52. package/src/task-tool.ts +135 -0
  53. package/src/tasks-ui.ts +73 -0
  54. package/src/tasks.ts +147 -0
  55. package/src/usage-view.ts +98 -0
  56. package/src/usage.ts +16 -0
  57. package/src/welcome.ts +260 -0
  58. package/src/working.ts +60 -0
  59. package/themes/pi-jar-dark-amber.json +84 -0
  60. package/themes/pi-jar-dark-azure.json +84 -0
  61. package/themes/pi-jar-dark-gray.json +84 -0
  62. package/themes/pi-jar-dark-pink.json +84 -0
  63. package/themes/pi-jar-dark-teal.json +84 -0
  64. package/themes/pi-jar-dark-violet.json +84 -0
  65. package/themes/pi-jar-dark.json +84 -0
@@ -0,0 +1,74 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ export interface SplitTheme { fg(color: string, text: string): string }
4
+
5
+ export interface SplitLayout {
6
+ /** First content row (0-based, local to the component). */
7
+ top: number;
8
+ /** Number of content rows. */
9
+ rows: number;
10
+ /** Width of the left pane content; 0 when collapsed to a single column. */
11
+ leftWidth: number;
12
+ /** Column where right-pane content starts. */
13
+ bodyX: number;
14
+ /** First footer row. */
15
+ footerTop: number;
16
+ }
17
+
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)));
21
+ };
22
+
23
+ /** Sidebar width for a split view, or 0 when the terminal is too narrow for two panes. */
24
+ export function sidebarWidth(width: number, min = 18, max = 32): number {
25
+ if (width < 64) return 0;
26
+ return Math.max(min, Math.min(max, Math.round(width * 0.26)));
27
+ }
28
+
29
+ /**
30
+ * Frame a two-pane view: `left` and `right` are already-windowed, styled rows.
31
+ * Left pane is omitted when `leftWidth` is 0. Every line fits `width` exactly.
32
+ */
33
+ export function splitFrame(theme: SplitTheme, width: number, title: string, left: readonly string[], right: readonly string[],
34
+ footer: readonly string[], rows: number, leftWidth = sidebarWidth(width)): { lines: string[]; layout: SplitLayout } {
35
+ const w = Math.max(12, width);
36
+ 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))))) + "×╮");
39
+ const lines = [top];
40
+ const split = leftWidth > 0;
41
+ const bodyWidth = split ? w - leftWidth - 6 : w - 4;
42
+ for (let row = 0; row < rows; row++) {
43
+ const body = pad(right[row] ?? "", bodyWidth);
44
+ lines.push(split
45
+ ? dim("│ ") + pad(left[row] ?? "", leftWidth) + dim("│ ") + body + dim(" │")
46
+ : dim("│ ") + body + dim(" │"));
47
+ }
48
+ lines.push(dim("├" + (split ? "─".repeat(leftWidth + 1) + "┴" + "─".repeat(Math.max(0, w - leftWidth - 4)) : "─".repeat(w - 2)) + "┤"));
49
+ const footerTop = lines.length;
50
+ for (const line of footer) lines.push(dim("│ ") + pad(line, w - 4) + dim(" │"));
51
+ 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
+ };
56
+ }
57
+
58
+ /** Available content rows for a full-height overlay with `chrome` fixed rows. */
59
+ export function contentRows(chrome: number, min = 4): number {
60
+ return Math.max(min, (process.stdout.rows ?? 24) - chrome);
61
+ }
62
+
63
+ /**
64
+ * Numbered options, one per row: `❯ 1 label` for the selected one (or none when `selected` is -1).
65
+ * Use for any set of choices so they read as a list rather than a crowded single line.
66
+ */
67
+ export function optionList(theme: SplitTheme & { bold?: (text: string) => string }, labels: readonly string[], selected: number, keys?: readonly string[]): string[] {
68
+ return labels.map((label, index) => {
69
+ const active = index === selected;
70
+ const key = keys?.[index] ?? String(index + 1);
71
+ const text = `${active ? "❯" : " "} ${key.padEnd(2)} ${label}`;
72
+ return theme.fg(active ? "accent" : "muted", active ? theme.bold?.(text) ?? text : text);
73
+ });
74
+ }
package/src/status.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { RoleStatus, RoleState } from "./roles.ts";
2
+
3
+ export const ROLE_PREFIX = "pi-jar.role.";
4
+ export const ACTIVE_STATES = new Set<RoleState>(["thinking", "working", "reviewing"]);
5
+ export const ROLE_TTL_MS = 30_000;
6
+ const STATES = new Set<RoleState>(["idle", "thinking", "working", "waiting", "reviewing", "done", "failed"]);
7
+
8
+ /** Remove terminal controls from third-party status text before placing it in our footer. */
9
+ export function cleanText(value: string, limit = 72): string {
10
+ return value.slice(0, 4096)
11
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g, "")
12
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
13
+ .replace(/\s+/g, " ")
14
+ .trim()
15
+ .slice(0, limit);
16
+ }
17
+
18
+ export interface JarRole extends RoleStatus {
19
+ expiresAt?: number;
20
+ }
21
+
22
+ export interface JarStatus {
23
+ roles: JarRole[];
24
+ extras: string[];
25
+ }
26
+
27
+ /** Explicit opt-in contract; never guess teammate state from arbitrary status prose. */
28
+ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: number): JarStatus {
29
+ const roles: JarRole[] = [];
30
+ const extras: string[] = [];
31
+ for (const [rawKey, rawValue] of statuses) {
32
+ const key = cleanText(rawKey, 48);
33
+ if (!key || typeof rawValue !== "string") continue;
34
+ if (rawKey.startsWith("pi-jar.quota.")) continue;
35
+ if (!rawKey.startsWith(ROLE_PREFIX)) {
36
+ const value = cleanText(rawValue);
37
+ // pi-jar's own indicators (such as `role:default`) are already self-describing.
38
+ if (value) extras.push(rawKey.startsWith("pi-jar.") ? value : `${key}: ${value}`);
39
+ continue;
40
+ }
41
+ const id = rawKey.slice(ROLE_PREFIX.length);
42
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
43
+ try {
44
+ if (rawValue.length > 4096) throw new Error("oversized status");
45
+ const data: unknown = JSON.parse(rawValue);
46
+ if (!data || typeof data !== "object") throw new Error("invalid status");
47
+ const role = data as Record<string, unknown>;
48
+ if (typeof role.name !== "string" || typeof role.state !== "string" || !STATES.has(role.state as RoleState)) {
49
+ throw new Error("invalid role");
50
+ }
51
+ const state = role.state as RoleState;
52
+ const expiresAt = role.expiresAt;
53
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= now || expiresAt > now + ROLE_TTL_MS) {
54
+ extras.push(`${id}: unavailable`);
55
+ continue;
56
+ }
57
+ const name = cleanText(role.name, 32) || id;
58
+ roles.push({
59
+ id,
60
+ name,
61
+ label: cleanText(typeof role.label === "string" ? role.label : name, 12) || id,
62
+ state,
63
+ task: typeof role.task === "string" ? cleanText(role.task, 48) : undefined,
64
+ expiresAt: typeof expiresAt === "number" ? expiresAt : undefined
65
+ });
66
+ } catch {
67
+ extras.push(`${id}: unavailable`);
68
+ }
69
+ }
70
+ return { roles: roles.slice(0, 16), extras: extras.slice(0, 16) };
71
+ }
package/src/suggest.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { cleanText } from "./status.ts";
5
+
6
+ export const SUGGEST_TOOL = "jar_suggest";
7
+ const REMINDER_TYPE = "pi-jar.suggest-reminder";
8
+ const MAX_SUGGESTION = 160;
9
+
10
+ /** The next-prompt suggestion shown as ghost text in an empty composer. Session-only, never persisted. */
11
+ export class SuggestionState {
12
+ private value: string | undefined;
13
+ private readonly listeners = new Set<() => void>();
14
+ get text(): string | undefined { return this.value; }
15
+ set(text: string): boolean {
16
+ const next = cleanText(text.replace(/\s+/g, " "), MAX_SUGGESTION);
17
+ if (!next) return false;
18
+ if (next !== this.value) { this.value = next; this.emit(); }
19
+ return true;
20
+ }
21
+ clear(): void { if (this.value !== undefined) { this.value = undefined; this.emit(); } }
22
+ onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
23
+ private emit(): void { for (const listener of this.listeners) listener(); }
24
+ }
25
+
26
+ export interface SuggestionOptions {
27
+ enabled: () => boolean;
28
+ /** Skip enforcement while another workflow owns the next step (plan review, goal loop). */
29
+ skip: () => boolean;
30
+ }
31
+
32
+ /** Ask the agent for one next-prompt suggestion per finished request, like an inline autocomplete. */
33
+ export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, options: SuggestionOptions): { sync(): void } {
34
+ let suggested = false;
35
+ let reminded = false;
36
+ pi.registerTool?.({
37
+ name: SUGGEST_TOOL,
38
+ label: "suggest",
39
+ description: "Offer the user one short next prompt. It appears as dimmed ghost text in their input box; Tab accepts it for editing. Call it once, last, when you finish a request.",
40
+ promptSnippet: "Finish each completed request by calling jar_suggest with the most useful next prompt for the user.",
41
+ promptGuidelines: [
42
+ "When you finish the user's request, call jar_suggest exactly once as your final action with the single most useful next prompt the user could send (imperative, one line, under 120 characters, written as the user would type it, e.g. \"Run the full test suite\").",
43
+ "Do not mention jar_suggest or the suggestion in your reply, and do not call it mid-task or while waiting for an answer."
44
+ ],
45
+ parameters: Type.Object({ suggestion: Type.String({ description: "The user's likely next prompt, one line." }) }),
46
+ execute: async (_id, params) => {
47
+ const ok = state.set(params.suggestion ?? "");
48
+ suggested ||= ok;
49
+ return { content: [{ type: "text", text: ok ? "Suggestion shown to the user. End your turn now." : "Suggestion was empty; skipped." }], details: { suggestion: state.text }, terminate: true };
50
+ },
51
+ renderCall() { return new Text("", 0, 0); },
52
+ renderResult(result, _options, theme) {
53
+ const text = (result.details as { suggestion?: string } | undefined)?.suggestion;
54
+ return new Text(text ? theme.fg("dim", "↳ next: " + text + " (Tab in the input box)") : "", 0, 0);
55
+ }
56
+ });
57
+
58
+ const sync = () => {
59
+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
60
+ const active = pi.getActiveTools();
61
+ const has = active.includes(SUGGEST_TOOL);
62
+ if (options.enabled() && !has && pi.getAllTools().some((tool) => tool.name === SUGGEST_TOOL)) pi.setActiveTools([...active, SUGGEST_TOOL]);
63
+ else if (!options.enabled() && has) pi.setActiveTools(active.filter((name) => name !== SUGGEST_TOOL));
64
+ if (!options.enabled()) state.clear();
65
+ };
66
+
67
+ pi.on("input", (event) => {
68
+ if (event.source !== "interactive") return;
69
+ suggested = false;
70
+ reminded = false;
71
+ state.clear();
72
+ });
73
+ pi.on("agent_start", () => { state.clear(); });
74
+ pi.on("session_start", () => { suggested = false; reminded = false; state.clear(); });
75
+ pi.on("session_tree", () => { state.clear(); });
76
+ pi.on("context", async (event) => ({ messages: event.messages.filter((raw) => (raw as { customType?: string }).customType !== REMINDER_TYPE) }));
77
+
78
+ pi.on("agent_before_settle", (event) => {
79
+ if (!options.enabled() || options.skip() || event.continue || event.outcome !== "completed" || suggested || reminded) return;
80
+ if (typeof pi.getActiveTools === "function" && !pi.getActiveTools().includes(SUGGEST_TOOL)) return;
81
+ reminded = true;
82
+ return {
83
+ entries: [{ type: "custom_message", customType: REMINDER_TYPE, display: false,
84
+ content: "[PI-JAR] Call jar_suggest now with the single most useful next prompt for the user. Do not add any other text." }],
85
+ continue: true
86
+ };
87
+ });
88
+ return { sync };
89
+ }
@@ -0,0 +1,135 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import type { Todo } from "./tasks.ts";
5
+ import { TODO_STATUSES, TodoStore, todoMark } from "./tasks.ts";
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 });
10
+
11
+ const Parameters = Type.Object({
12
+ todos: Type.Optional(Type.Array(Type.Object({
13
+ content: Type.String({ description: "Imperative task title, e.g. \"Run the tests\"." }),
14
+ 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." })),
17
+ 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." })),
20
+ details: Type.Optional(Type.String({ description: "Optional short task context for a new task." }))
21
+ });
22
+
23
+ interface TaskToolDetails {
24
+ action: string;
25
+ items: Todo[];
26
+ changed?: string;
27
+ }
28
+
29
+ const summary = (items: readonly Todo[]) => {
30
+ const done = items.filter((item) => item.done).length;
31
+ const current = items.find((item) => item.status === "in_progress");
32
+ return `${done}/${items.length} done` + (current ? ` · now: ${current.title}` : "");
33
+ };
34
+
35
+ const lines = (items: readonly Todo[]) => items.length
36
+ ? items.map((item) => `${todoMark(item)} [${item.status}] ${item.title} (${item.id})`).join("\n")
37
+ : "No tracked tasks.";
38
+
39
+ 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.";
40
+
41
+ export function registerTaskTool(
42
+ pi: ExtensionAPI,
43
+ store: () => TodoStore | undefined,
44
+ changed: (ctx: ExtensionContext) => void
45
+ ): void {
46
+ if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
47
+ pi.registerTool({
48
+ name: "jar_todo",
49
+ label: "tasks",
50
+ 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.",
51
+ promptSnippet: "Track multi-step work with jar_todo: write the full list, keep exactly one task in_progress, complete tasks as they finish.",
52
+ promptGuidelines: [
53
+ "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.",
54
+ "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.",
55
+ "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.",
56
+ "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."
57
+ ],
58
+ parameters: Parameters,
59
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
60
+ const current = store();
61
+ const action = params.todos ? "write" : params.action ?? "list";
62
+ if (!current) {
63
+ return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [] } satisfies TaskToolDetails };
64
+ }
65
+ let changedId: string | undefined;
66
+ let error: string | undefined;
67
+ switch (action) {
68
+ case "list":
69
+ break;
70
+ case "write":
71
+ if (!params.todos) { error = "write needs `todos`: the complete updated list."; break; }
72
+ error = current.write(params.todos.map((item) => ({ title: item.content, status: item.status, ...(item.activeForm ? { activeForm: item.activeForm } : {}) })));
73
+ break;
74
+ case "add": {
75
+ const item = params.title?.trim() ? current.add(params.title, params.details) : undefined;
76
+ if (!item) error = "add needs a short title.";
77
+ changedId = item?.id;
78
+ break;
79
+ }
80
+ case "start":
81
+ case "done":
82
+ case "open":
83
+ 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.`;
84
+ else changedId = params.id;
85
+ break;
86
+ case "edit":
87
+ if (!params.id || !params.title?.trim() || !current.edit(params.id, params.title)) error = "edit needs a valid id and a title.";
88
+ else changedId = params.id;
89
+ break;
90
+ case "delete":
91
+ if (!params.id || !current.delete(params.id)) error = "delete needs a valid id from jar_todo list.";
92
+ else changedId = params.id;
93
+ break;
94
+ }
95
+ if (action !== "list" && !error) changed(ctx);
96
+ const items = current.all();
97
+ const message = error
98
+ ? `Could not ${action} tasks: ${error}\n${lines(items)}`
99
+ : `${action === "list" ? "Tasks" : "Task list updated"} (${summary(items)}):\n${lines(items)}${action === "list" ? "" : "\n" + REMINDER}`;
100
+ return {
101
+ content: [{ type: "text", text: message }],
102
+ details: { action, items, ...(changedId ? { changed: changedId } : {}) } satisfies TaskToolDetails
103
+ };
104
+ },
105
+ renderCall(args, theme) {
106
+ let text = theme.fg("toolTitle", theme.bold("tasks"));
107
+ if (args.todos) text += " " + theme.fg("accent", `update · ${args.todos.length} item${args.todos.length === 1 ? "" : "s"}`);
108
+ else {
109
+ text += " " + theme.fg("accent", args.action ?? "list");
110
+ if (args.title) text += " " + theme.fg("muted", args.title);
111
+ }
112
+ return new Text(text, 0, 0);
113
+ },
114
+ renderResult(result, { expanded, isPartial }, theme) {
115
+ if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
116
+ const details = result.details as TaskToolDetails | undefined;
117
+ const items = details?.items ?? [];
118
+ if (!items.length) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
119
+ // Like Claude: the checklist itself is the result. Collapsed shows up to 8 rows.
120
+ const shown = expanded ? items : items.slice(0, 8);
121
+ let text = theme.fg("dim", summary(items));
122
+ for (const item of shown) text += "\n" + todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value));
123
+ if (shown.length < items.length) text += "\n" + theme.fg("dim", ` … +${items.length - shown.length} more (expand)`);
124
+ return new Text(text, 0, 0);
125
+ }
126
+ });
127
+ }
128
+
129
+ type TodoColor = "accent" | "muted" | "dim" | "success";
130
+ /** One checklist row: completed rows are struck through, the running one is bold. */
131
+ export function todoRow(item: Todo, fg: (color: TodoColor, text: string) => string, bold: (text: string) => string = (text) => text): string {
132
+ if (item.status === "completed") return fg("success", " ✔ ") + fg("dim", "\x1b[9m" + item.title + "\x1b[29m");
133
+ if (item.status === "in_progress") return fg("accent", " ◼ ") + fg("accent", bold(item.title));
134
+ return fg("muted", " ☐ " + item.title);
135
+ }
@@ -0,0 +1,73 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { promptChoice, promptText, todoView, type TodoFilter } from "./dialogs.ts";
3
+ import { TodoStore } from "./tasks.ts";
4
+
5
+ const help = "Usage: /jar tasks [list|add TITLE|done ID|open ID|edit ID TITLE|delete ID]";
6
+
7
+ export async function manageTasks(args: string, ctx: ExtensionContext, store: TodoStore, changed: () => void): Promise<void> {
8
+ const notify = (text: string, severity: "info" | "error" = "info") => {
9
+ if (ctx.hasUI) ctx.ui.notify(text, severity);
10
+ else console.log(text);
11
+ };
12
+ const list = () => {
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)");
15
+ };
16
+ if (!args && ctx.hasUI && ctx.mode === "tui") {
17
+ const filter: { value: TodoFilter } = { value: "all" };
18
+ while (true) {
19
+ const action = await todoView(ctx, () => store.all(), filter);
20
+ if (!action) break;
21
+ let success = false;
22
+ if (action.kind === "add") {
23
+ const title = await promptText(ctx, "Add pi-jar to-do", "What needs doing?");
24
+ if (title) success = !!store.add(title);
25
+ } else if (action.id && action.kind === "toggle") success = store.toggle(action.id);
26
+ else if (action.id && action.kind === "edit") {
27
+ const previous = store.get(action.id);
28
+ const title = previous && await promptText(ctx, "Edit pi-jar to-do", "Update the title", previous.title);
29
+ if (title) success = store.edit(action.id, title);
30
+ } else if (action.id && action.kind === "delete") {
31
+ const item = store.get(action.id);
32
+ const choice = item && await promptChoice(ctx, "Delete to-do?", item.title, ["Delete item", "Keep item"], 1);
33
+ if (choice === 0) success = store.delete(action.id);
34
+ }
35
+ if (success) changed();
36
+ }
37
+ return;
38
+ }
39
+ const [verb = "list", id, ...titleParts] = args.split(/\s+/);
40
+ switch (verb.toLowerCase()) {
41
+ case "list": list(); return;
42
+ case "add": {
43
+ const title = args.slice(4).trim();
44
+ if (store.add(title)) { changed(); notify("Added pi-jar to-do"); }
45
+ else notify(help, "error");
46
+ return;
47
+ }
48
+ case "done":
49
+ case "open": {
50
+ const item = id && store.get(id);
51
+ if (item && item.done !== (verb === "done") && store.toggle(id)) { changed(); notify("Updated pi-jar to-do"); }
52
+ else notify("To-do not found or already in that state", "error");
53
+ return;
54
+ }
55
+ case "edit": {
56
+ const title = titleParts.join(" ");
57
+ if (id && store.edit(id, title)) { changed(); notify("Edited pi-jar to-do"); }
58
+ else notify(help, "error");
59
+ return;
60
+ }
61
+ case "delete": {
62
+ if (!id || !store.get(id)) { notify("To-do not found", "error"); return; }
63
+ 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);
65
+ if (choice !== 0) return;
66
+ }
67
+ // Headless deletion is explicit and requires the full ID; TUI deletion asks for confirmation.
68
+ if (store.delete(id)) { changed(); notify("Deleted pi-jar to-do"); }
69
+ return;
70
+ }
71
+ default: notify(help, "error");
72
+ }
73
+ }
package/src/tasks.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { cleanText } from "./status.ts";
3
+
4
+ export const TASK_ENTRY = "pi-jar.task";
5
+ export const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
6
+ export type TodoStatus = (typeof TODO_STATUSES)[number];
7
+ /** `done` mirrors `status === "completed"` for callers that only need open/closed. */
8
+ export interface Todo { id: string; title: string; done: boolean; status: TodoStatus; activeForm?: string; details?: string }
9
+ export interface TodoInput { title: string; status: TodoStatus; activeForm?: string }
10
+ export const MAX_TODOS = 50;
11
+
12
+ interface WrittenTodo { id: string; title: string; status: TodoStatus; activeForm?: string }
13
+ export type TodoEvent =
14
+ | { v: 1; op: "add"; id: string; title: string; details?: string }
15
+ | { v: 1; op: "edit"; id: string; title: string }
16
+ | { v: 1; op: "toggle"; id: string; done: boolean }
17
+ | { v: 1; op: "status"; id: string; status: TodoStatus }
18
+ | { v: 1; op: "delete"; id: string }
19
+ /** Full-list replacement, like a Claude todo write. */
20
+ | { v: 1; op: "write"; items: WrittenTodo[] };
21
+
22
+ const validId = (id: unknown): id is string => typeof id === "string" && /^[a-zA-Z0-9-]{1,64}$/.test(id);
23
+ const validTitle = (title: unknown): title is string => typeof title === "string" && title.length <= 256 && !!cleanText(title, 120);
24
+ const validStatus = (status: unknown): status is TodoStatus => TODO_STATUSES.includes(status as TodoStatus);
25
+ const make = (id: string, title: string, status: TodoStatus, activeForm?: unknown, details?: unknown): Todo => ({
26
+ id, title: cleanText(title, 120), status, done: status === "completed",
27
+ ...(typeof activeForm === "string" && cleanText(activeForm, 80) ? { activeForm: cleanText(activeForm, 80) } : {}),
28
+ ...(typeof details === "string" && cleanText(details, 240) ? { details: cleanText(details, 240) } : {})
29
+ });
30
+
31
+ /** Session-local, append-only changes: only the current Pi branch is replayed. */
32
+ export class TodoStore {
33
+ private items = new Map<string, Todo>();
34
+ private readonly append: (event: TodoEvent) => void;
35
+ constructor(append: (event: TodoEvent) => void) { this.append = append; }
36
+ all(): Todo[] { return [...this.items.values()].map((item) => ({ ...item })); }
37
+ get(id: string): Todo | undefined { const item = this.items.get(id); return item && { ...item }; }
38
+ /** The task being worked on right now, if any. */
39
+ current(): Todo | undefined { const item = [...this.items.values()].find((todo) => todo.status === "in_progress"); return item && { ...item }; }
40
+
41
+ private setItemStatus(item: Todo, status: TodoStatus): void {
42
+ // Exactly one task is in progress: starting one parks the previous one.
43
+ if (status === "in_progress") for (const other of this.items.values()) if (other !== item && other.status === "in_progress") { other.status = "pending"; other.done = false; }
44
+ item.status = status;
45
+ item.done = status === "completed";
46
+ }
47
+
48
+ apply(raw: unknown): boolean {
49
+ if (!raw || typeof raw !== "object") return false;
50
+ const event = raw as Record<string, unknown>;
51
+ if (event.v !== 1) return false;
52
+ if (event.op === "write") {
53
+ if (!Array.isArray(event.items) || event.items.length > MAX_TODOS) return false;
54
+ const next = new Map<string, Todo>();
55
+ for (const raw of event.items as unknown[]) {
56
+ const item = raw as Record<string, unknown> | null;
57
+ if (!item || !validId(item.id) || !validTitle(item.title) || !validStatus(item.status) || next.has(item.id)) return false;
58
+ next.set(item.id, make(item.id, item.title, item.status, item.activeForm, this.items.get(item.id)?.details));
59
+ }
60
+ if ([...next.values()].filter((item) => item.status === "in_progress").length > 1) return false;
61
+ this.items = next;
62
+ return true;
63
+ }
64
+ if (!validId(event.id)) return false;
65
+ const item = this.items.get(event.id);
66
+ switch (event.op) {
67
+ case "add":
68
+ if (!validTitle(event.title) || item) return false;
69
+ this.items.set(event.id, make(event.id, event.title, "pending", undefined, event.details));
70
+ return true;
71
+ case "edit":
72
+ if (!validTitle(event.title) || !item) return false;
73
+ item.title = cleanText(event.title, 120);
74
+ return true;
75
+ case "toggle":
76
+ if (typeof event.done !== "boolean" || !item) return false;
77
+ this.setItemStatus(item, event.done ? "completed" : "pending");
78
+ return true;
79
+ case "status":
80
+ if (!validStatus(event.status) || !item) return false;
81
+ this.setItemStatus(item, event.status);
82
+ return true;
83
+ case "delete": return this.items.delete(event.id);
84
+ default: return false;
85
+ }
86
+ }
87
+ restore(branch: readonly unknown[]): void {
88
+ this.items.clear();
89
+ for (const raw of branch) {
90
+ if (!raw || typeof raw !== "object") continue;
91
+ const entry = raw as Record<string, unknown>;
92
+ if (entry.type === "custom" && entry.customType === TASK_ENTRY) this.apply(entry.data);
93
+ }
94
+ }
95
+ private commit(event: TodoEvent): boolean {
96
+ const before = new Map([...this.items].map(([id, item]) => [id, { ...item }]));
97
+ if (!this.apply(event)) return false;
98
+ try { this.append(event); return true; }
99
+ catch { this.items = before; return false; }
100
+ }
101
+ add(title: string, details?: string): Todo | undefined {
102
+ if (!validTitle(title)) return undefined;
103
+ const id = randomUUID();
104
+ const cleanDetails = typeof details === "string" && details.trim() ? cleanText(details, 240) : undefined;
105
+ return this.commit({ v: 1, op: "add", id, title, ...(cleanDetails ? { details: cleanDetails } : {}) }) ? this.get(id) : undefined;
106
+ }
107
+ /**
108
+ * Replace the whole list. Items keep their id when an existing task has the same title,
109
+ * so history and the goal loop see continuity. Returns an error message on invalid input.
110
+ */
111
+ write(inputs: readonly TodoInput[]): string | undefined {
112
+ if (inputs.length > MAX_TODOS) return `At most ${MAX_TODOS} tasks.`;
113
+ if (inputs.some((item) => !validTitle(item.title))) return "Every task needs a short, non-empty title.";
114
+ if (inputs.some((item) => !validStatus(item.status))) return `status must be one of ${TODO_STATUSES.join(", ")}.`;
115
+ if (inputs.filter((item) => item.status === "in_progress").length > 1) return "Only one task may be in_progress at a time.";
116
+ const free = new Map<string, string[]>();
117
+ for (const item of this.items.values()) {
118
+ const key = cleanText(item.title, 120).toLowerCase();
119
+ free.set(key, [...(free.get(key) ?? []), item.id]);
120
+ }
121
+ const items = inputs.map((item): WrittenTodo => {
122
+ const id = free.get(cleanText(item.title, 120).toLowerCase())?.shift() ?? randomUUID();
123
+ const activeForm = typeof item.activeForm === "string" && item.activeForm.trim() ? item.activeForm : undefined;
124
+ return { id, title: item.title, status: item.status, ...(activeForm ? { activeForm } : {}) };
125
+ });
126
+ return this.commit({ v: 1, op: "write", items }) ? undefined : "Could not save the task list.";
127
+ }
128
+ edit(id: string, title: string): boolean { return this.commit({ v: 1, op: "edit", id, title }); }
129
+ setStatus(id: string, status: TodoStatus): boolean {
130
+ const item = this.items.get(id);
131
+ if (!item) return false;
132
+ return item.status === status || this.commit({ v: 1, op: "status", id, status });
133
+ }
134
+ setDone(id: string, done: boolean): boolean {
135
+ const item = this.items.get(id);
136
+ if (!item) return false;
137
+ return item.done === done || this.setStatus(id, done ? "completed" : "pending");
138
+ }
139
+ toggle(id: string): boolean {
140
+ const item = this.items.get(id);
141
+ return !!item && this.setDone(id, !item.done);
142
+ }
143
+ delete(id: string): boolean { return this.commit({ v: 1, op: "delete", id }); }
144
+ }
145
+
146
+ /** Claude-style checkbox glyph for a task. */
147
+ export const todoMark = (item: Pick<Todo, "status">) => item.status === "completed" ? "✔" : item.status === "in_progress" ? "◼" : "☐";