@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/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const TODO_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
|
2
|
+
export type TodoStatus = typeof TODO_STATUSES[number];
|
|
3
|
+
|
|
4
|
+
export const TODO_ACTIONS = ["set", "add", "transition", "view"] as const;
|
|
5
|
+
export type TodoActionName = typeof TODO_ACTIONS[number];
|
|
6
|
+
|
|
7
|
+
export type Todo = {
|
|
8
|
+
name: string;
|
|
9
|
+
status: TodoStatus;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type TodoPhase = {
|
|
13
|
+
name: string;
|
|
14
|
+
tasks: Todo[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TodoState = {
|
|
18
|
+
phases: TodoPhase[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TodoAddress = {
|
|
22
|
+
phase: string;
|
|
23
|
+
task: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Persisted with a successful tool result so session state can be restored. */
|
|
27
|
+
export type TodoToolDetails = {
|
|
28
|
+
action: TodoActionName;
|
|
29
|
+
state: TodoState;
|
|
30
|
+
changedTasks: TodoAddress[];
|
|
31
|
+
completedTasks: TodoAddress[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type TodoPhaseInput = {
|
|
35
|
+
name: string;
|
|
36
|
+
tasks: string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type TodoTransitionInput = TodoAddress & {
|
|
40
|
+
status: TodoStatus;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SetTodoAction = {
|
|
44
|
+
action: "set";
|
|
45
|
+
phases: TodoPhaseInput[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type AddTodoAction = {
|
|
49
|
+
action: "add";
|
|
50
|
+
phases: TodoPhaseInput[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type TransitionTodoAction = {
|
|
54
|
+
action: "transition";
|
|
55
|
+
transitions: TodoTransitionInput[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type ViewTodoAction = {
|
|
59
|
+
action: "view";
|
|
60
|
+
phase?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type TodoAction = SetTodoAction | AddTodoAction | TransitionTodoAction | ViewTodoAction;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TodoToolVisibility } from "./settings.js";
|
|
2
|
+
import { TODO_ACTIONS, type TodoActionName } from "./types.js";
|
|
3
|
+
|
|
4
|
+
function isTodoActionName(action: unknown): action is TodoActionName {
|
|
5
|
+
return typeof action === "string" && (TODO_ACTIONS as readonly string[]).includes(action);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Returns whether a successful todo action should be shown in tool output. */
|
|
9
|
+
export function shouldRenderTodoAction(action: unknown, visibility: TodoToolVisibility): boolean {
|
|
10
|
+
if (!isTodoActionName(action)) return false;
|
|
11
|
+
|
|
12
|
+
switch (visibility) {
|
|
13
|
+
case "all":
|
|
14
|
+
return true;
|
|
15
|
+
case "set-only":
|
|
16
|
+
return action === "set";
|
|
17
|
+
case "none":
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import type { TodoState } from "./types.js";
|
|
5
|
+
import { renderTodoWidgetLines, type TodoWidgetLayoutOptions } from "./widget-layout.js";
|
|
6
|
+
|
|
7
|
+
const DROPLET_FRAMES = ["", "", "", "", ""] as const;
|
|
8
|
+
const DROPLET_DURATIONS_MS = [220, 110, 80, 100, 420] as const;
|
|
9
|
+
const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
10
|
+
const PI_SPINNER_INTERVAL_MS = 80;
|
|
11
|
+
|
|
12
|
+
/** A width-aware component for Pi's persistent widget area. */
|
|
13
|
+
export class TodoWidgetComponent implements Component {
|
|
14
|
+
private frameIndex = 0;
|
|
15
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly state: TodoState,
|
|
19
|
+
private readonly theme: Theme | undefined,
|
|
20
|
+
private readonly options: TodoWidgetLayoutOptions = {},
|
|
21
|
+
private readonly tui?: Pick<TUI, "requestRender">,
|
|
22
|
+
) {
|
|
23
|
+
if (tui && state.phases.some(phase => phase.tasks.some(task => task.status === "in_progress"))) {
|
|
24
|
+
this.scheduleNextFrame();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
invalidate(): void { }
|
|
29
|
+
|
|
30
|
+
render(width: number): string[] {
|
|
31
|
+
const safeWidth = Math.max(1, Math.floor(width) || 1);
|
|
32
|
+
return renderTodoWidgetLines(this.state, this.theme, safeWidth, {
|
|
33
|
+
...this.options,
|
|
34
|
+
activeMarker: this.frames[this.frameIndex],
|
|
35
|
+
}).flatMap(line => wrapTextWithAnsi(line, safeWidth));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
dispose(): void {
|
|
39
|
+
if (this.timer) clearTimeout(this.timer);
|
|
40
|
+
this.timer = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private get frames(): readonly string[] {
|
|
44
|
+
return this.options.fallbackGlyphs ? PI_SPINNER_FRAMES : DROPLET_FRAMES;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private scheduleNextFrame(): void {
|
|
48
|
+
const delay = this.options.fallbackGlyphs
|
|
49
|
+
? PI_SPINNER_INTERVAL_MS
|
|
50
|
+
: DROPLET_DURATIONS_MS[this.frameIndex];
|
|
51
|
+
this.timer = setTimeout(() => {
|
|
52
|
+
this.frameIndex = (this.frameIndex + 1) % this.frames.length;
|
|
53
|
+
this.tui?.requestRender();
|
|
54
|
+
this.scheduleNextFrame();
|
|
55
|
+
}, delay);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { todoGlyph } from "./glyphs.js";
|
|
5
|
+
import type { Todo, TodoPhase, TodoState } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export type TodoWidgetLayoutOptions = {
|
|
8
|
+
maxVisible?: number;
|
|
9
|
+
fallbackGlyphs?: boolean;
|
|
10
|
+
activeMarker?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ThemeLike = Partial<Pick<Theme, "bold" | "fg" | "strikethrough">>;
|
|
14
|
+
|
|
15
|
+
type DisplayTask = Todo & { taskIndex: number };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Produces compact widget rows. Every returned row is constrained to the supplied display width;
|
|
19
|
+
* the component may subsequently wrap a row when a host chooses a narrower cell.
|
|
20
|
+
*/
|
|
21
|
+
export function renderTodoWidgetLines(
|
|
22
|
+
state: TodoState | undefined,
|
|
23
|
+
theme: ThemeLike | undefined,
|
|
24
|
+
width: number,
|
|
25
|
+
options: TodoWidgetLayoutOptions = {},
|
|
26
|
+
): string[] {
|
|
27
|
+
const safeWidth = Math.max(1, Math.floor(width) || 1);
|
|
28
|
+
const phases = Array.isArray(state?.phases) ? state.phases : [];
|
|
29
|
+
if (phases.length === 0 || !phases.some(phase => phase.tasks.length > 0)) return [];
|
|
30
|
+
|
|
31
|
+
const selectedPhaseIndex = selectPhase(phases);
|
|
32
|
+
if (selectedPhaseIndex < 0) return [];
|
|
33
|
+
const selectedPhase = phases[selectedPhaseIndex];
|
|
34
|
+
const maxVisible = boundedMaxVisible(options.maxVisible);
|
|
35
|
+
const selectedTasks = visibleTasks(selectedPhase.tasks, maxVisible);
|
|
36
|
+
const lines: string[] = [fit(toolTitle(summary(phases), theme), safeWidth)];
|
|
37
|
+
|
|
38
|
+
for (let phaseIndex = 0; phaseIndex < phases.length; phaseIndex++) {
|
|
39
|
+
const phase = phases[phaseIndex];
|
|
40
|
+
const selected = phaseIndex === selectedPhaseIndex;
|
|
41
|
+
lines.push(fit(phaseTitle(phase, phaseIndex, selected, theme), safeWidth));
|
|
42
|
+
|
|
43
|
+
if (selected) {
|
|
44
|
+
for (const task of selectedTasks) {
|
|
45
|
+
lines.push(fit(taskLine(task, theme, options.fallbackGlyphs, options.activeMarker), safeWidth));
|
|
46
|
+
}
|
|
47
|
+
const openTasks = phase.tasks.filter(task => !isTerminal(task));
|
|
48
|
+
const hidden = openTasks.length - selectedTasks.length;
|
|
49
|
+
if (hidden > 0) lines.push(fit(` +${hidden} more`, safeWidth));
|
|
50
|
+
const terminalSummary = terminalTaskSummary(phase.tasks);
|
|
51
|
+
if (terminalSummary) {
|
|
52
|
+
const line = ` + ${terminalSummary}`;
|
|
53
|
+
lines.push(fit(theme?.fg ? theme.fg("dim", line) : line, safeWidth));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Alias kept for callers that prefer a builder-style name. */
|
|
62
|
+
export const buildTodoWidgetLines = renderTodoWidgetLines;
|
|
63
|
+
|
|
64
|
+
function boundedMaxVisible(value: number | undefined): number {
|
|
65
|
+
if (!Number.isFinite(value)) return 5;
|
|
66
|
+
return Math.max(1, Math.floor(value!));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function summary(phases: TodoPhase[]): string {
|
|
70
|
+
const tasks = phases.flatMap(phase => phase.tasks);
|
|
71
|
+
const active = tasks.filter(isActive).length;
|
|
72
|
+
const pending = tasks.filter(task => task.status === "pending").length;
|
|
73
|
+
const completed = tasks.filter(task => task.status === "completed").length;
|
|
74
|
+
const cancelled = tasks.filter(task => task.status === "cancelled").length;
|
|
75
|
+
return [
|
|
76
|
+
"Todos",
|
|
77
|
+
...(active ? [`${active} active`] : []),
|
|
78
|
+
...(pending ? [`${pending} pending`] : []),
|
|
79
|
+
...(completed ? [`${completed} completed`] : []),
|
|
80
|
+
...(cancelled ? [`${cancelled} cancelled`] : []),
|
|
81
|
+
].join(" · ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function phaseTitle(phase: TodoPhase, phaseIndex: number, selected: boolean, theme: ThemeLike | undefined): string {
|
|
85
|
+
const title = `${phaseIndex + 1}. ${phaseSummary(phase)}`;
|
|
86
|
+
if (selected) return toolTitle(` ${title}`, theme);
|
|
87
|
+
return theme?.fg ? theme.fg("dim", ` ${title}`) : ` ${title}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function phaseSummary(phase: TodoPhase): string {
|
|
91
|
+
const active = phase.tasks.filter(isActive).length;
|
|
92
|
+
const pending = phase.tasks.filter(task => task.status === "pending").length;
|
|
93
|
+
const completed = phase.tasks.filter(task => task.status === "completed").length;
|
|
94
|
+
const cancelled = phase.tasks.filter(task => task.status === "cancelled").length;
|
|
95
|
+
return [
|
|
96
|
+
phase.name,
|
|
97
|
+
...(active ? [`${active} active`] : []),
|
|
98
|
+
...(pending ? [`${pending} pending`] : []),
|
|
99
|
+
...(completed ? [`${completed} completed`] : []),
|
|
100
|
+
...(cancelled ? [`${cancelled} cancelled`] : []),
|
|
101
|
+
].join(" · ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toolTitle(text: string, theme: ThemeLike | undefined): string {
|
|
105
|
+
const bold = theme?.bold ? theme.bold(text) : text;
|
|
106
|
+
return theme?.fg ? theme.fg("toolTitle", bold) : bold;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function visibleTasks(tasks: Todo[], maxVisible: number): DisplayTask[] {
|
|
110
|
+
const ordered = tasks
|
|
111
|
+
.map((task, taskIndex) => ({ ...task, taskIndex }))
|
|
112
|
+
.filter(task => !isTerminal(task))
|
|
113
|
+
.sort((left, right) => taskPriority(left) - taskPriority(right) || left.taskIndex - right.taskIndex);
|
|
114
|
+
const active = ordered.filter(isActive);
|
|
115
|
+
return active.length > maxVisible ? active : ordered.slice(0, maxVisible);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function taskLine(task: Todo, theme: ThemeLike | undefined, fallbackGlyphs = false, activeMarker?: string): string {
|
|
119
|
+
const marker = isActive(task) && activeMarker ? activeMarker : todoGlyph(task.status, fallbackGlyphs);
|
|
120
|
+
const color = task.status === "in_progress" ? "text" : task.status === "completed" ? "success" : "dim";
|
|
121
|
+
const name = (task.status === "completed" || task.status === "cancelled") && theme?.strikethrough
|
|
122
|
+
? theme.strikethrough(task.name)
|
|
123
|
+
: task.name;
|
|
124
|
+
let line = ` ${marker} ${name}`;
|
|
125
|
+
if (isActive(task) && theme?.bold) line = theme.bold(line);
|
|
126
|
+
return theme?.fg ? theme.fg(color, line) : line;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function taskPriority(task: Todo): number {
|
|
130
|
+
if (isActive(task)) return 0;
|
|
131
|
+
if (task.status === "pending") return 1;
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function selectPhase(phases: TodoPhase[]): number {
|
|
136
|
+
const active = phases.findIndex(phase => phase.tasks.some(isActive));
|
|
137
|
+
if (active >= 0) return active;
|
|
138
|
+
return phases.findIndex(phase => phase.tasks.some(task => task.status === "pending"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function terminalTaskSummary(tasks: Todo[]): string | undefined {
|
|
142
|
+
const completed = tasks.filter(task => task.status === "completed").length;
|
|
143
|
+
const cancelled = tasks.filter(task => task.status === "cancelled").length;
|
|
144
|
+
const parts = [
|
|
145
|
+
...(completed ? [`${completed} complete ${completed === 1 ? "task" : "tasks"}`] : []),
|
|
146
|
+
...(cancelled ? [`${cancelled} cancelled ${cancelled === 1 ? "task" : "tasks"}`] : []),
|
|
147
|
+
];
|
|
148
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isActive(task: Todo): boolean {
|
|
152
|
+
return task.status === "in_progress";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isTerminal(task: Todo): boolean {
|
|
156
|
+
return task.status === "completed" || task.status === "cancelled";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function fit(line: string, width: number): string {
|
|
160
|
+
return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
|
|
161
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import type { TodoState } from "./types.js";
|
|
5
|
+
import { TodoWidgetComponent } from "./widget-component.js";
|
|
6
|
+
|
|
7
|
+
export type TodoWidgetPlacement = "belowEditor" | "aboveEditor" | "off";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Deliberately structural so this unit does not depend on the settings persistence layer.
|
|
11
|
+
* The widget-prefixed forms allow a future settings module to expose namespaced UI settings.
|
|
12
|
+
*/
|
|
13
|
+
export type TodoWidgetSettings = {
|
|
14
|
+
widgetPlacement?: TodoWidgetPlacement;
|
|
15
|
+
maxVisibleTasks?: number;
|
|
16
|
+
fallbackGlyphs?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type WidgetComponentFactory = (tui: TUI, theme: Theme) => Component & { dispose?(): void };
|
|
20
|
+
|
|
21
|
+
export type TodoWidgetContext = {
|
|
22
|
+
hasUI?: boolean;
|
|
23
|
+
ui?: {
|
|
24
|
+
notify?: (message: string, level?: "info" | "warning" | "error") => void;
|
|
25
|
+
setWidget?: {
|
|
26
|
+
(id: string, content: string[] | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
|
|
27
|
+
(id: string, content: WidgetComponentFactory | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Update (or clear) the persistent todo widget without requiring the host UI at runtime. */
|
|
33
|
+
export function updateTodoWidget(ctx: TodoWidgetContext | undefined, state: TodoState | undefined, settings: TodoWidgetSettings = {}): void {
|
|
34
|
+
if (!ctx?.hasUI || !ctx.ui?.setWidget) return;
|
|
35
|
+
try {
|
|
36
|
+
const placement = settings.widgetPlacement ?? "aboveEditor";
|
|
37
|
+
if (placement === "off") {
|
|
38
|
+
ctx.ui.setWidget("todo", undefined);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hasVisibleTasks = state?.phases.some(phase => phase.tasks.some(task =>
|
|
43
|
+
task.status === "pending" || task.status === "in_progress",
|
|
44
|
+
)) ?? false;
|
|
45
|
+
const factory: WidgetComponentFactory | undefined = hasVisibleTasks
|
|
46
|
+
? (tui, theme) => new TodoWidgetComponent(state!, theme, {
|
|
47
|
+
maxVisible: settings.maxVisibleTasks,
|
|
48
|
+
fallbackGlyphs: settings.fallbackGlyphs,
|
|
49
|
+
}, tui)
|
|
50
|
+
: undefined;
|
|
51
|
+
ctx.ui.setWidget("todo", factory, { placement });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
try {
|
|
54
|
+
ctx.ui.notify?.(`Todo widget update failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
55
|
+
} catch { }
|
|
56
|
+
}
|
|
57
|
+
}
|