@pi9/todo 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.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +59 -0
- package/src/format.ts +61 -0
- package/src/glyphs.ts +21 -0
- package/src/index.ts +6 -0
- package/src/persistence.ts +87 -0
- package/src/renderer.ts +147 -0
- package/src/schema.ts +35 -0
- package/src/settings.ts +160 -0
- package/src/state.ts +203 -0
- package/src/tool-frame.ts +136 -0
- package/src/tool.ts +194 -0
- package/src/types.ts +63 -0
- package/src/visibility.ts +20 -0
- package/src/widget-component.ts +57 -0
- package/src/widget-layout.ts +161 -0
- package/src/widget.ts +57 -0
package/src/settings.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export type TodoWidgetPlacement = "aboveEditor" | "belowEditor" | "off";
|
|
7
|
+
export type TodoToolVisibility = "all" | "set-only" | "none";
|
|
8
|
+
|
|
9
|
+
export interface TodoUiSettings {
|
|
10
|
+
widgetPlacement: TodoWidgetPlacement;
|
|
11
|
+
maxVisibleTasks: number;
|
|
12
|
+
fallbackGlyphs: boolean;
|
|
13
|
+
toolVisibility: TodoToolVisibility;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The portion of Pi's session context needed to load project-specific settings. */
|
|
17
|
+
export interface TodoSettingsContext {
|
|
18
|
+
cwd: string;
|
|
19
|
+
isProjectTrusted(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TodoUiSettingsLoadResult {
|
|
23
|
+
settings: TodoUiSettings;
|
|
24
|
+
warning?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TodoUiSettingsStoreOptions {
|
|
28
|
+
globalSettingsPath?: string;
|
|
29
|
+
projectSettingsPath?: (cwd: string) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_TODO_UI_SETTINGS: TodoUiSettings = {
|
|
33
|
+
widgetPlacement: "aboveEditor",
|
|
34
|
+
maxVisibleTasks: 5,
|
|
35
|
+
fallbackGlyphs: false,
|
|
36
|
+
toolVisibility: "set-only",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const WIDGET_PLACEMENTS = new Set<TodoWidgetPlacement>(["aboveEditor", "belowEditor", "off"]);
|
|
40
|
+
const TOOL_VISIBILITIES = new Set<TodoToolVisibility>(["all", "set-only", "none"]);
|
|
41
|
+
|
|
42
|
+
export function getTodoGlobalSettingsPath(): string {
|
|
43
|
+
return join(getAgentDir(), "todo", "settings.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getTodoProjectSettingsPath(cwd: string): string {
|
|
47
|
+
return join(cwd, CONFIG_DIR_NAME, "todo", "settings.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Loads global settings and, only for a trusted project, project-local overrides.
|
|
52
|
+
* Pass the event handler's `ctx` directly to `load` or `loadTodoUiSettings`.
|
|
53
|
+
*/
|
|
54
|
+
export class TodoUiSettingsStore {
|
|
55
|
+
private readonly globalSettingsPath: string;
|
|
56
|
+
private readonly projectSettingsPath: (cwd: string) => string;
|
|
57
|
+
|
|
58
|
+
constructor(options: TodoUiSettingsStoreOptions = {}) {
|
|
59
|
+
this.globalSettingsPath = options.globalSettingsPath ?? getTodoGlobalSettingsPath();
|
|
60
|
+
this.projectSettingsPath = options.projectSettingsPath ?? getTodoProjectSettingsPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async load(context?: TodoSettingsContext): Promise<TodoUiSettingsLoadResult> {
|
|
64
|
+
const settings = cloneDefaults();
|
|
65
|
+
const warnings: string[] = [];
|
|
66
|
+
|
|
67
|
+
await applyFile(this.globalSettingsPath, settings, warnings);
|
|
68
|
+
|
|
69
|
+
// Pi resolves trust before session handlers run. Never read project-controlled settings unless
|
|
70
|
+
// that resolved context says the project is trusted.
|
|
71
|
+
if (context && isTrusted(context)) {
|
|
72
|
+
await applyFile(this.projectSettingsPath(context.cwd), settings, warnings);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { settings, ...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Convenience API for loading settings from a Pi session/event context. */
|
|
80
|
+
export async function loadTodoUiSettings(context?: TodoSettingsContext): Promise<TodoUiSettingsLoadResult> {
|
|
81
|
+
return new TodoUiSettingsStore().load(context);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Validates an object as a complete settings input, defaulting invalid or absent fields. */
|
|
85
|
+
export function normalizeTodoUiSettings(value: unknown): TodoUiSettingsLoadResult {
|
|
86
|
+
const settings = cloneDefaults();
|
|
87
|
+
const warnings: string[] = [];
|
|
88
|
+
applySettings(value, settings, warnings);
|
|
89
|
+
return { settings, ...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function applyFile(path: string, settings: TodoUiSettings, warnings: string[]): Promise<void> {
|
|
93
|
+
let raw: string;
|
|
94
|
+
try {
|
|
95
|
+
raw = await readFile(path, "utf8");
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
98
|
+
warnings.push(`Could not read todo settings at ${path}; using available defaults.`);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
applySettings(JSON.parse(raw) as unknown, settings, warnings);
|
|
105
|
+
} catch {
|
|
106
|
+
warnings.push(`Invalid todo settings at ${path}; using available defaults.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function applySettings(value: unknown, settings: TodoUiSettings, warnings: string[]): void {
|
|
111
|
+
if (!isRecord(value)) {
|
|
112
|
+
warnings.push("Invalid todo settings; using available defaults.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (value.widgetPlacement !== undefined) {
|
|
117
|
+
if (WIDGET_PLACEMENTS.has(value.widgetPlacement as TodoWidgetPlacement)) {
|
|
118
|
+
settings.widgetPlacement = value.widgetPlacement as TodoWidgetPlacement;
|
|
119
|
+
} else {
|
|
120
|
+
warnings.push("Invalid todo widgetPlacement; ignoring value.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (value.maxVisibleTasks !== undefined) {
|
|
124
|
+
if (Number.isInteger(value.maxVisibleTasks) && (value.maxVisibleTasks as number) > 0) {
|
|
125
|
+
settings.maxVisibleTasks = value.maxVisibleTasks as number;
|
|
126
|
+
} else {
|
|
127
|
+
warnings.push("Invalid todo maxVisibleTasks; ignoring value.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (value.fallbackGlyphs !== undefined) {
|
|
131
|
+
if (typeof value.fallbackGlyphs === "boolean") {
|
|
132
|
+
settings.fallbackGlyphs = value.fallbackGlyphs;
|
|
133
|
+
} else {
|
|
134
|
+
warnings.push("Invalid todo fallbackGlyphs; ignoring value.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (value.toolVisibility !== undefined) {
|
|
138
|
+
if (TOOL_VISIBILITIES.has(value.toolVisibility as TodoToolVisibility)) {
|
|
139
|
+
settings.toolVisibility = value.toolVisibility as TodoToolVisibility;
|
|
140
|
+
} else {
|
|
141
|
+
warnings.push("Invalid todo toolVisibility; ignoring value.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cloneDefaults(): TodoUiSettings {
|
|
147
|
+
return { ...DEFAULT_TODO_UI_SETTINGS };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
151
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isTrusted(context: TodoSettingsContext): boolean {
|
|
155
|
+
try {
|
|
156
|
+
return context.isProjectTrusted();
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { TODO_ACTIONS, TODO_STATUSES, type TodoAction, type TodoPhase, type TodoPhaseInput, type TodoState, type TodoStatus, type TodoTransitionInput } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function createTodoState(): TodoState {
|
|
4
|
+
return { phases: [] };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function cloneTodoState(state: TodoState): TodoState {
|
|
8
|
+
return {
|
|
9
|
+
phases: state.phases.map((phase) => ({
|
|
10
|
+
name: phase.name,
|
|
11
|
+
tasks: phase.tasks.map((task) => ({ ...task })),
|
|
12
|
+
})),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Applies an action atomically without mutating the supplied state or action. */
|
|
17
|
+
export function transitionTodoState(state: TodoState, action: TodoAction | unknown): TodoState {
|
|
18
|
+
assertState(state);
|
|
19
|
+
const input = record(action, "Todo action");
|
|
20
|
+
const actionName = input.action;
|
|
21
|
+
if (typeof actionName !== "string" || !(TODO_ACTIONS as readonly string[]).includes(actionName)) {
|
|
22
|
+
throw new Error(`Todo action must be one of: ${TODO_ACTIONS.join(", ")}.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const next = cloneTodoState(state);
|
|
26
|
+
switch (actionName) {
|
|
27
|
+
case "set":
|
|
28
|
+
assertOnlyFields(input, ["action", "phases"], "set");
|
|
29
|
+
next.phases = parsePhases(input.phases).map(newPhase);
|
|
30
|
+
break;
|
|
31
|
+
case "add":
|
|
32
|
+
assertOnlyFields(input, ["action", "phases"], "add");
|
|
33
|
+
addPhases(next, parsePhases(input.phases));
|
|
34
|
+
break;
|
|
35
|
+
case "transition":
|
|
36
|
+
assertOnlyFields(input, ["action", "transitions"], "transition");
|
|
37
|
+
applyTransitions(next, parseTransitions(input.transitions));
|
|
38
|
+
break;
|
|
39
|
+
case "view":
|
|
40
|
+
assertOnlyFields(input, ["action", "phase"], "view");
|
|
41
|
+
if (input.phase !== undefined) next.phases = [findPhase(next.phases, name(input.phase, "view phase"))];
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
assertState(next);
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const applyTodoAction = transitionTodoState;
|
|
50
|
+
|
|
51
|
+
function newPhase(input: TodoPhaseInput): TodoPhase {
|
|
52
|
+
return { name: input.name, tasks: input.tasks.map((task) => ({ name: task, status: "pending" })) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addPhases(state: TodoState, inputs: TodoPhaseInput[]): void {
|
|
56
|
+
if (!inputs.some((phase) => phase.tasks.length > 0)) {
|
|
57
|
+
throw new Error("add requires at least one task.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const input of inputs) {
|
|
61
|
+
let phase = state.phases.find((candidate) => candidate.name === input.name);
|
|
62
|
+
if (!phase) {
|
|
63
|
+
phase = { name: input.name, tasks: [] };
|
|
64
|
+
state.phases.push(phase);
|
|
65
|
+
}
|
|
66
|
+
const names = new Set(phase.tasks.map((task) => task.name));
|
|
67
|
+
for (const task of input.tasks) {
|
|
68
|
+
if (names.has(task)) throw new Error(`Duplicate task name in phase ${input.name}: ${task}.`);
|
|
69
|
+
names.add(task);
|
|
70
|
+
phase.tasks.push({ name: task, status: "pending" });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function applyTransitions(state: TodoState, transitions: TodoTransitionInput[]): void {
|
|
76
|
+
if (transitions.length === 0) throw new Error("transition requires at least one status change.");
|
|
77
|
+
const addressed = new Set<string>();
|
|
78
|
+
|
|
79
|
+
for (const transition of transitions) {
|
|
80
|
+
const key = addressKey(transition.phase, transition.task);
|
|
81
|
+
if (addressed.has(key)) throw new Error(`Task may only be transitioned once per call: ${transition.phase} / ${transition.task}.`);
|
|
82
|
+
addressed.add(key);
|
|
83
|
+
|
|
84
|
+
const phase = state.phases.find((candidate) => candidate.name === transition.phase);
|
|
85
|
+
if (!phase) throw new Error(phaseNotFoundMessage(state.phases, transition.phase));
|
|
86
|
+
const task = phase.tasks.find((candidate) => candidate.name === transition.task);
|
|
87
|
+
if (!task) throw new Error(taskNotFoundMessage(phase, transition.task));
|
|
88
|
+
task.status = transition.status;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parsePhases(value: unknown): TodoPhaseInput[] {
|
|
93
|
+
if (!Array.isArray(value)) throw new Error("phases must be an array.");
|
|
94
|
+
const phases = value.map((item, phaseIndex) => {
|
|
95
|
+
const input = record(item, `phases[${phaseIndex}]`);
|
|
96
|
+
assertOnlyFields(input, ["name", "tasks"], `phases[${phaseIndex}]`);
|
|
97
|
+
const phaseName = name(input.name, `phases[${phaseIndex}].name`);
|
|
98
|
+
if (!Array.isArray(input.tasks)) throw new Error(`phases[${phaseIndex}].tasks must be an array.`);
|
|
99
|
+
const tasks = input.tasks.map((task, taskIndex) => name(task, `phases[${phaseIndex}].tasks[${taskIndex}]`));
|
|
100
|
+
assertUnique(tasks, (task) => `Duplicate task name in phase ${phaseName}: ${task}.`);
|
|
101
|
+
return { name: phaseName, tasks };
|
|
102
|
+
});
|
|
103
|
+
assertUnique(phases.map((phase) => phase.name), (phase) => `Duplicate phase name: ${phase}.`);
|
|
104
|
+
return phases;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseTransitions(value: unknown): TodoTransitionInput[] {
|
|
108
|
+
if (!Array.isArray(value)) throw new Error("transitions must be an array.");
|
|
109
|
+
return value.map((item, index) => {
|
|
110
|
+
const input = record(item, `transitions[${index}]`);
|
|
111
|
+
assertOnlyFields(input, ["phase", "task", "status"], `transitions[${index}]`);
|
|
112
|
+
return {
|
|
113
|
+
phase: name(input.phase, `transitions[${index}].phase`),
|
|
114
|
+
task: name(input.task, `transitions[${index}].task`),
|
|
115
|
+
status: status(input.status, `transitions[${index}].status`),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
121
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
122
|
+
return value as Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function name(value: unknown, label: string): string {
|
|
126
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string.`);
|
|
127
|
+
if (value !== value.trim()) throw new Error(`${label} must not have leading or trailing whitespace.`);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function status(value: unknown, label: string): TodoStatus {
|
|
132
|
+
if (typeof value !== "string" || !(TODO_STATUSES as readonly string[]).includes(value)) {
|
|
133
|
+
throw new Error(`${label} must be one of: ${TODO_STATUSES.join(", ")}.`);
|
|
134
|
+
}
|
|
135
|
+
return value as TodoStatus;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assertOnlyFields(input: Record<string, unknown>, allowed: string[], label: string): void {
|
|
139
|
+
const unexpected = Object.keys(input).find((key) => !allowed.includes(key));
|
|
140
|
+
if (unexpected) throw new Error(`${label} does not accept field: ${unexpected}.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertUnique(values: string[], message: (value: string) => string): void {
|
|
144
|
+
const seen = new Set<string>();
|
|
145
|
+
for (const value of values) {
|
|
146
|
+
if (seen.has(value)) throw new Error(message(value));
|
|
147
|
+
seen.add(value);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findPhase(phases: TodoPhase[], phaseName: string): TodoPhase {
|
|
152
|
+
const phase = phases.find((candidate) => candidate.name === phaseName);
|
|
153
|
+
if (!phase) throw new Error(phaseNotFoundMessage(phases, phaseName));
|
|
154
|
+
return phase;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function phaseNotFoundMessage(phases: TodoPhase[], phaseName: string): string {
|
|
158
|
+
const names = phases.map((phase) => `- ${phase.name}`).join("\n");
|
|
159
|
+
return names ? `Phase not found: ${phaseName}.\n\nCurrent phases:\n${names}` : `Phase not found: ${phaseName}. The todo plan is empty.`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function taskNotFoundMessage(phase: TodoPhase, taskName: string): string {
|
|
163
|
+
const names = phase.tasks.map((task) => `- ${task.name}`).join("\n");
|
|
164
|
+
return names
|
|
165
|
+
? `Task not found: ${phase.name} / ${taskName}.\n\nCurrent tasks in ${phase.name}:\n${names}`
|
|
166
|
+
: `Task not found: ${phase.name} / ${taskName}. Phase ${phase.name} has no tasks.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function todoAddressKey(phase: string, task: string): string {
|
|
170
|
+
return addressKey(phase, task);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function addressKey(phase: string, task: string): string {
|
|
174
|
+
return `${phase}\0${task}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function assertState(state: TodoState): void {
|
|
178
|
+
if (!state || typeof state !== "object" || !Array.isArray(state.phases)) throw new Error("Invalid todo state.");
|
|
179
|
+
const phaseNames = new Set<string>();
|
|
180
|
+
let activePhase: string | undefined;
|
|
181
|
+
|
|
182
|
+
for (const phase of state.phases) {
|
|
183
|
+
if (!phase || typeof phase !== "object" || !Array.isArray(phase.tasks)) throw new Error("Invalid todo state.");
|
|
184
|
+
const phaseName = name(phase.name, "phase name");
|
|
185
|
+
if (phaseNames.has(phaseName)) throw new Error("Invalid todo state: duplicate phase name.");
|
|
186
|
+
phaseNames.add(phaseName);
|
|
187
|
+
|
|
188
|
+
const taskNames = new Set<string>();
|
|
189
|
+
for (const task of phase.tasks) {
|
|
190
|
+
if (!task || typeof task !== "object") throw new Error("Invalid todo state.");
|
|
191
|
+
const taskName = name(task.name, "task name");
|
|
192
|
+
if (taskNames.has(taskName)) throw new Error("Invalid todo state: duplicate task name.");
|
|
193
|
+
if (!(TODO_STATUSES as readonly string[]).includes(task.status)) throw new Error("Invalid todo state.");
|
|
194
|
+
if (task.status === "in_progress") {
|
|
195
|
+
if (activePhase !== undefined && activePhase !== phaseName) {
|
|
196
|
+
throw new Error("Invalid todo state: in_progress tasks must all belong to one phase.");
|
|
197
|
+
}
|
|
198
|
+
activePhase = phaseName;
|
|
199
|
+
}
|
|
200
|
+
taskNames.add(taskName);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Box, Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export type TodoToolFrameState = "pending" | "success" | "error";
|
|
5
|
+
export type TodoToolFrameContent = Component | string | readonly (Component | string)[] | null | undefined;
|
|
6
|
+
export type TodoToolFrameTheme = Partial<Pick<Theme, "fg" | "bg" | "bold">>;
|
|
7
|
+
|
|
8
|
+
export interface TodoToolFrameOptions {
|
|
9
|
+
title?: string;
|
|
10
|
+
action?: string;
|
|
11
|
+
state?: TodoToolFrameState;
|
|
12
|
+
content?: TodoToolFrameContent;
|
|
13
|
+
paddingX?: number;
|
|
14
|
+
paddingY?: number;
|
|
15
|
+
empty?: "hide" | "frame";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type FrameColor = Parameters<Theme["fg"]>[0];
|
|
19
|
+
type FrameBackground = Parameters<Theme["bg"]>[0];
|
|
20
|
+
|
|
21
|
+
type FrameStyle = {
|
|
22
|
+
state: FrameColor;
|
|
23
|
+
background: FrameBackground;
|
|
24
|
+
marker: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const FRAME_STYLES: Record<TodoToolFrameState, FrameStyle> = {
|
|
28
|
+
pending: {
|
|
29
|
+
state: "warning",
|
|
30
|
+
background: "toolPendingBg",
|
|
31
|
+
marker: "●",
|
|
32
|
+
},
|
|
33
|
+
success: {
|
|
34
|
+
state: "success",
|
|
35
|
+
background: "toolSuccessBg",
|
|
36
|
+
marker: "✓",
|
|
37
|
+
},
|
|
38
|
+
error: {
|
|
39
|
+
state: "error",
|
|
40
|
+
background: "toolErrorBg",
|
|
41
|
+
marker: "✗",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const DEFAULT_TITLE = "todo";
|
|
46
|
+
|
|
47
|
+
export class TodoToolFrame implements Component {
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly options: TodoToolFrameOptions,
|
|
50
|
+
private readonly theme?: TodoToolFrameTheme,
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
invalidate(): void {
|
|
54
|
+
for (const entry of contentEntries(this.options.content)) {
|
|
55
|
+
if (typeof entry !== "string") entry.invalidate();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render(width: number): string[] {
|
|
60
|
+
const safeWidth = normalizeWidth(width);
|
|
61
|
+
const paddingX = normalizePadding(this.options.paddingX, 1);
|
|
62
|
+
const paddingY = normalizePadding(this.options.paddingY, 1);
|
|
63
|
+
const contentWidth = Math.max(1, safeWidth - paddingX * 2);
|
|
64
|
+
const content = this.options.content;
|
|
65
|
+
const hasContent = hasRenderableContent(content, contentWidth);
|
|
66
|
+
|
|
67
|
+
if (!hasContent && this.options.empty !== "frame") return [];
|
|
68
|
+
|
|
69
|
+
const state = this.options.state ?? "pending";
|
|
70
|
+
const style = FRAME_STYLES[state];
|
|
71
|
+
const boxPaddingX = Math.min(paddingX, Math.floor(safeWidth / 2));
|
|
72
|
+
const box = new Box(
|
|
73
|
+
boxPaddingX,
|
|
74
|
+
paddingY,
|
|
75
|
+
this.theme?.bg
|
|
76
|
+
? (text: string) => this.theme!.bg!(style.background, text)
|
|
77
|
+
: undefined,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
box.addChild(new Text(this.renderHeader(state, style), 0, 0));
|
|
81
|
+
if (hasContent) {
|
|
82
|
+
for (const entry of contentEntries(content)) {
|
|
83
|
+
box.addChild(typeof entry === "string"
|
|
84
|
+
? new Text(this.paint("toolOutput", entry), 0, 0)
|
|
85
|
+
: entry);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return box.render(safeWidth);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private renderHeader(state: TodoToolFrameState, style: FrameStyle): string {
|
|
93
|
+
const title = normalizeLabel(this.options.title === undefined ? DEFAULT_TITLE : this.options.title);
|
|
94
|
+
const action = normalizeLabel(this.options.action);
|
|
95
|
+
const label = [title ? this.paint("toolTitle", this.bold(title)) : "", action ? this.paint("muted", action) : ""]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(" ");
|
|
98
|
+
const marker = this.paint(style.state, `${style.marker} ${state}`);
|
|
99
|
+
return [label, marker].filter(Boolean).join(" ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private paint(color: FrameColor, text: string): string {
|
|
103
|
+
return this.theme?.fg ? this.theme.fg(color, text) : text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private bold(text: string): string {
|
|
107
|
+
return this.theme?.bold ? this.theme.bold(text) : text;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeWidth(width: number): number {
|
|
113
|
+
return Math.max(1, Math.floor(width) || 1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizePadding(value: number | undefined, fallback: number): number {
|
|
117
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
118
|
+
return Math.max(0, Math.floor(value));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeLabel(value: string | undefined): string {
|
|
122
|
+
return typeof value === "string" ? value.trim() : "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function contentEntries(content: TodoToolFrameContent): readonly (Component | string)[] {
|
|
126
|
+
if (content === null || content === undefined) return [];
|
|
127
|
+
if (Array.isArray(content)) return content as readonly (Component | string)[];
|
|
128
|
+
return [content as Component | string];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasRenderableContent(content: TodoToolFrameContent, width: number): boolean {
|
|
132
|
+
return contentEntries(content).some((entry) => {
|
|
133
|
+
if (typeof entry === "string") return entry.trim().length > 0;
|
|
134
|
+
return entry.render(width).length > 0;
|
|
135
|
+
});
|
|
136
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatTodoSummary } from "./format.js";
|
|
4
|
+
import { restoreTodoState } from "./persistence.js";
|
|
5
|
+
import { renderResult as renderTodoResult } from "./renderer.js";
|
|
6
|
+
import { TodoToolFrame, type TodoToolFrameContent, type TodoToolFrameTheme } from "./tool-frame.js";
|
|
7
|
+
import { TodoParamsSchema } from "./schema.js";
|
|
8
|
+
import { DEFAULT_TODO_UI_SETTINGS, loadTodoUiSettings, type TodoUiSettings } from "./settings.js";
|
|
9
|
+
import { createTodoState, todoAddressKey, transitionTodoState } from "./state.js";
|
|
10
|
+
import { TODO_STATUSES, type TodoAction, type TodoAddress, type TodoState, type TodoToolDetails } from "./types.js";
|
|
11
|
+
import { shouldRenderTodoAction } from "./visibility.js";
|
|
12
|
+
import { updateTodoWidget } from "./widget.js";
|
|
13
|
+
|
|
14
|
+
function taskStatuses(state: TodoState): Map<string, string> {
|
|
15
|
+
return new Map(state.phases.flatMap((phase) => phase.tasks.map((task) => [todoAddressKey(phase.name, task.name), task.status])));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function taskAddresses(state: TodoState): Map<string, TodoAddress> {
|
|
19
|
+
return new Map(state.phases.flatMap((phase) => phase.tasks.map((task) => {
|
|
20
|
+
const address = { phase: phase.name, task: task.name };
|
|
21
|
+
return [todoAddressKey(address.phase, address.task), address];
|
|
22
|
+
})));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function changedTasks(previous: TodoState, next: TodoState): TodoAddress[] {
|
|
26
|
+
const before = taskStatuses(previous);
|
|
27
|
+
const after = taskStatuses(next);
|
|
28
|
+
const addresses = taskAddresses(next);
|
|
29
|
+
return [...after.keys()].filter((key) => before.get(key) !== after.get(key)).map((key) => addresses.get(key)!);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function completedTasks(previous: TodoState, next: TodoState): TodoAddress[] {
|
|
33
|
+
const before = taskStatuses(previous);
|
|
34
|
+
return next.phases.flatMap((phase) => phase.tasks
|
|
35
|
+
.filter((task) => task.status === "completed" && before.has(todoAddressKey(phase.name, task.name)) && before.get(todoAddressKey(phase.name, task.name)) !== "completed")
|
|
36
|
+
.map((task) => ({ phase: phase.name, task: task.name })));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createTodoFrame(
|
|
40
|
+
state: "pending" | "success" | "error",
|
|
41
|
+
action: string | undefined,
|
|
42
|
+
content: TodoToolFrameContent,
|
|
43
|
+
theme: TodoToolFrameTheme,
|
|
44
|
+
): TodoToolFrame {
|
|
45
|
+
return new TodoToolFrame({
|
|
46
|
+
title: "todo",
|
|
47
|
+
action,
|
|
48
|
+
state,
|
|
49
|
+
content,
|
|
50
|
+
empty: "frame",
|
|
51
|
+
}, theme);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type TodoRenderInput = {
|
|
55
|
+
details?: TodoToolDetails;
|
|
56
|
+
content?: readonly { type?: string; text?: string }[];
|
|
57
|
+
};
|
|
58
|
+
type TodoRenderTheme = Parameters<typeof renderTodoResult>[2];
|
|
59
|
+
type TrackedSetRenderer = { toolCallId: string; invalidate?: () => void };
|
|
60
|
+
|
|
61
|
+
/** Expanded content for the one set result that is allowed to follow in-memory state. */
|
|
62
|
+
class LiveSetResult implements Component {
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly result: TodoRenderInput,
|
|
65
|
+
private readonly getState: () => TodoState,
|
|
66
|
+
private readonly isCurrent: () => boolean,
|
|
67
|
+
private readonly theme: TodoRenderTheme,
|
|
68
|
+
private readonly fallbackGlyphs: boolean,
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
invalidate(): void {}
|
|
72
|
+
|
|
73
|
+
render(width: number): string[] {
|
|
74
|
+
const details = this.result.details;
|
|
75
|
+
const liveResult: TodoRenderInput = !this.isCurrent() || !details
|
|
76
|
+
? this.result
|
|
77
|
+
: { ...this.result, details: { ...details, state: this.getState(), changedTasks: [] } };
|
|
78
|
+
return renderTodoResult(liveResult, { expanded: true }, this.theme, { fallbackGlyphs: this.fallbackGlyphs }).render(width);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerTodoTool(pi: ExtensionAPI): void {
|
|
83
|
+
let state = createTodoState();
|
|
84
|
+
let settings: TodoUiSettings = { ...DEFAULT_TODO_UI_SETTINGS };
|
|
85
|
+
let queue: Promise<void> = Promise.resolve();
|
|
86
|
+
let latestSetRenderer: TrackedSetRenderer | undefined;
|
|
87
|
+
|
|
88
|
+
const invalidateLatestSetRenderer = (): void => {
|
|
89
|
+
latestSetRenderer?.invalidate?.();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const restore = (ctx: ExtensionContext): void => {
|
|
93
|
+
state = restoreTodoState(ctx);
|
|
94
|
+
invalidateLatestSetRenderer();
|
|
95
|
+
updateTodoWidget(ctx, state, settings);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
99
|
+
const loaded = await loadTodoUiSettings(ctx);
|
|
100
|
+
settings = loaded.settings;
|
|
101
|
+
if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
|
|
102
|
+
restore(ctx);
|
|
103
|
+
});
|
|
104
|
+
pi.on("session_tree", (_event, ctx) => restore(ctx));
|
|
105
|
+
|
|
106
|
+
pi.registerTool({
|
|
107
|
+
name: "todo",
|
|
108
|
+
label: "Todo",
|
|
109
|
+
description: [
|
|
110
|
+
"Maintain a concise phased plan that reflects both intended work and current execution progress.",
|
|
111
|
+
"Actions:",
|
|
112
|
+
" set: Replace the entire plan. All tasks reset to pending. Use only for a new plan or full re-plan.",
|
|
113
|
+
" add: Append tasks or phases without touching existing work.",
|
|
114
|
+
" transition: Update task statuses (" + TODO_STATUSES.join(", ") + ") by exact phase and task name.",
|
|
115
|
+
" view: Return the plan, optionally filtered to one phase.",
|
|
116
|
+
].join("\n"),
|
|
117
|
+
promptSnippet: "Track multi-step work with the todo tool; keep statuses current as you go",
|
|
118
|
+
promptGuidelines: [
|
|
119
|
+
"Use todo for non-trivial work with three or more distinct steps; skip todo for simple tasks.",
|
|
120
|
+
"Transition todo statuses immediately and honestly—mark tasks completed only when fully done, not merely attempted, and cancel abandoned tasks rather than leaving them pending.",
|
|
121
|
+
"Todo tasks in_progress must all belong to a single phase; finish or cancel a phase's active tasks before starting the next.",
|
|
122
|
+
],
|
|
123
|
+
parameters: TodoParamsSchema,
|
|
124
|
+
renderShell: "self",
|
|
125
|
+
|
|
126
|
+
execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
127
|
+
const run = queue.then(() => {
|
|
128
|
+
const previous = state;
|
|
129
|
+
const next = transitionTodoState(previous, params as TodoAction);
|
|
130
|
+
const details: TodoToolDetails = {
|
|
131
|
+
action: params.action,
|
|
132
|
+
state: next,
|
|
133
|
+
changedTasks: params.action === "view"
|
|
134
|
+
? []
|
|
135
|
+
: params.action === "set"
|
|
136
|
+
? [...taskAddresses(next).values()]
|
|
137
|
+
: changedTasks(previous, next),
|
|
138
|
+
completedTasks: params.action === "transition" ? completedTasks(previous, next) : [],
|
|
139
|
+
};
|
|
140
|
+
if (params.action !== "view") {
|
|
141
|
+
state = next;
|
|
142
|
+
invalidateLatestSetRenderer();
|
|
143
|
+
}
|
|
144
|
+
updateTodoWidget(ctx, state, settings);
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text" as const, text: formatTodoSummary(next) }],
|
|
147
|
+
details,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
queue = run.then(() => undefined, () => undefined);
|
|
151
|
+
return run;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
renderCall(args, theme, context) {
|
|
155
|
+
// The self shell replaces the call with the final result once execution settles. Keeping
|
|
156
|
+
// this slot empty after completion also prevents a visible call and result frame at once.
|
|
157
|
+
if (!context.isPartial || context.isError || !shouldRenderTodoAction(args.action, settings.toolVisibility)) {
|
|
158
|
+
return new Container();
|
|
159
|
+
}
|
|
160
|
+
return createTodoFrame("pending", args.action, undefined, theme);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
renderResult(result, options, theme, context) {
|
|
164
|
+
const details = result.details as TodoToolDetails | undefined;
|
|
165
|
+
const action = details?.action ?? context.args.action;
|
|
166
|
+
if (!context.isError && !shouldRenderTodoAction(action, settings.toolVisibility)) return new Container();
|
|
167
|
+
|
|
168
|
+
const isSetResult = !context.isError && details?.action === "set";
|
|
169
|
+
if (isSetResult) {
|
|
170
|
+
if (!latestSetRenderer || latestSetRenderer.toolCallId !== context.toolCallId) {
|
|
171
|
+
latestSetRenderer = { toolCallId: context.toolCallId, invalidate: context.invalidate };
|
|
172
|
+
} else {
|
|
173
|
+
latestSetRenderer.invalidate = context.invalidate;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Partial updates are represented by the pending call frame. This keeps streaming updates
|
|
178
|
+
// from briefly rendering two self-owned frames in one tool row.
|
|
179
|
+
if ((options.isPartial || context.isPartial) && !context.isError) return new Container();
|
|
180
|
+
|
|
181
|
+
const renderInput = result as TodoRenderInput;
|
|
182
|
+
const content = isSetResult && options.expanded
|
|
183
|
+
? new LiveSetResult(
|
|
184
|
+
renderInput,
|
|
185
|
+
() => state,
|
|
186
|
+
() => latestSetRenderer?.toolCallId === context.toolCallId,
|
|
187
|
+
theme,
|
|
188
|
+
settings.fallbackGlyphs,
|
|
189
|
+
)
|
|
190
|
+
: renderTodoResult(renderInput, options, theme, { fallbackGlyphs: settings.fallbackGlyphs });
|
|
191
|
+
return createTodoFrame(context.isError ? "error" : "success", action, content, theme);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|