@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chase Cummings
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @pi9/todo
|
|
2
|
+
|
|
3
|
+
A phased, branch-aware todo tool for the [Pi coding agent](https://github.com/earendil-works/pi-mono).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Concise phased plans with immutable task names
|
|
8
|
+
- Destructive plan replacement and non-destructive task addition
|
|
9
|
+
- Atomic status transitions addressed by exact phase and task names
|
|
10
|
+
- Explicit `pending`, `in_progress`, `completed`, and `cancelled` statuses
|
|
11
|
+
- State restored from the active Pi session branch
|
|
12
|
+
- A persistent, configurable todo widget above or below the editor
|
|
13
|
+
- Rich tool rendering with active-task summaries and expandable phase progress
|
|
14
|
+
- Native-style self-rendered tool shells with no extra spacing for hidden activity
|
|
15
|
+
|
|
16
|
+
Todo snapshots are stored in tool-result details, so `/tree` navigation restores the plan associated with that branch.
|
|
17
|
+
|
|
18
|
+
## Tool contract
|
|
19
|
+
|
|
20
|
+
The provider-facing schema is one flat object rather than a union, which keeps it compatible across Pi providers. Action-specific requirements are validated atomically by the tool.
|
|
21
|
+
|
|
22
|
+
### Set the complete plan
|
|
23
|
+
|
|
24
|
+
`set` discards the complete current plan and creates exactly the supplied phases and tasks. Every task starts `pending`. An empty `phases` array clears the plan.
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"action": "set",
|
|
29
|
+
"phases": [
|
|
30
|
+
{
|
|
31
|
+
"name": "Build",
|
|
32
|
+
"tasks": ["Implement session restoration", "Add integration coverage"]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Add newly discovered work
|
|
39
|
+
|
|
40
|
+
`add` creates missing phases or appends tasks to existing phases without changing current tasks or statuses. New tasks start `pending`.
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"action": "add",
|
|
45
|
+
"phases": [
|
|
46
|
+
{
|
|
47
|
+
"name": "Verify",
|
|
48
|
+
"tasks": ["Run the complete test suite"]
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Transition task statuses
|
|
55
|
+
|
|
56
|
+
`transition` applies status changes atomically using exact phase and task names.
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"action": "transition",
|
|
61
|
+
"transitions": [
|
|
62
|
+
{
|
|
63
|
+
"phase": "Build",
|
|
64
|
+
"task": "Implement session restoration",
|
|
65
|
+
"status": "completed"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"phase": "Verify",
|
|
69
|
+
"task": "Run the complete test suite",
|
|
70
|
+
"status": "in_progress"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Phase names and task names are immutable. Task names must be unique within their phase. Cancel obsolete tasks instead of removing them; cancel and add a corrected task when its name needs to change. All `in_progress` tasks must belong to one phase.
|
|
77
|
+
|
|
78
|
+
### View the plan
|
|
79
|
+
|
|
80
|
+
`view` returns the complete plan or one exact phase:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{ "action": "view", "phase": "Build" }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pi install npm:@pi9/todo
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For local development:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pi -e ./packages/todo/src/index.ts
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## UI settings
|
|
99
|
+
|
|
100
|
+
The settings loader reads global settings from `~/.pi/agent/todo/settings.json`. For a trusted project, `.pi/todo/settings.json` overrides the global values. Pi's project-trust decision is required before the project file is read; an untrusted project cannot affect these settings.
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"widgetPlacement": "aboveEditor",
|
|
105
|
+
"maxVisibleTasks": 5,
|
|
106
|
+
"fallbackGlyphs": false,
|
|
107
|
+
"toolVisibility": "set-only"
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`widgetPlacement` accepts `"aboveEditor"`, `"belowEditor"`, or `"off"`. `maxVisibleTasks` must be a positive integer, and `fallbackGlyphs` must be a boolean. Nerd Font status glyphs are the default; set `fallbackGlyphs` to `true` to use broadly supported Unicode symbols instead.
|
|
112
|
+
|
|
113
|
+
`toolVisibility` controls Todo tool output in the terminal UI only:
|
|
114
|
+
|
|
115
|
+
- `"all"` shows every Todo action.
|
|
116
|
+
- `"set-only"` shows only `set` operations.
|
|
117
|
+
- `"none"` hides normal Todo activity.
|
|
118
|
+
|
|
119
|
+
Errors are always shown. Todo output uses native-style self-rendered shells, and hidden successful operations render zero lines. When expanded, the latest rendered `set` result on the active branch follows later additions and transitions; historical details and collapsed rendering remain unchanged.
|
|
120
|
+
|
|
121
|
+
Settings load when a session starts. The widget refreshes after todo changes and `/tree` navigation. Set `widgetPlacement` to `"off"` to disable it.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm run typecheck --workspace @pi9/todo
|
|
127
|
+
npm test --workspace @pi9/todo
|
|
128
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi9/todo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for managing agent todo tasks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Chase Cummings <chaseecummings@gmail.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"types": "src/index.ts",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Chase-C/pi9.git",
|
|
13
|
+
"directory": "packages/todo"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Chase-C/pi9/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/Chase-C/pi9/tree/main/packages/todo#readme",
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pi",
|
|
26
|
+
"pi-extension",
|
|
27
|
+
"pi-package",
|
|
28
|
+
"pi9",
|
|
29
|
+
"todo"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.0.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./src/index.ts"
|
|
46
|
+
],
|
|
47
|
+
"skills": [],
|
|
48
|
+
"prompts": []
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@earendil-works/pi-coding-agent": "^0.80.6",
|
|
55
|
+
"@types/node": "^24.0.0",
|
|
56
|
+
"typescript": "~6.0.3",
|
|
57
|
+
"vitest": "^4.1.6"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Todo, TodoState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type PhasedTodo = Todo & { phase: string };
|
|
4
|
+
|
|
5
|
+
export interface TodoCounts {
|
|
6
|
+
open: number;
|
|
7
|
+
completed: number;
|
|
8
|
+
cancelled: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A small, plain-text representation used in tool results and model context. */
|
|
12
|
+
export function formatTodoSummary(state: TodoState | undefined): string {
|
|
13
|
+
const tasks = todoTasks(state);
|
|
14
|
+
if (tasks.length === 0) return "No todo tasks.";
|
|
15
|
+
|
|
16
|
+
const counts = countTodos(tasks);
|
|
17
|
+
const summary = [
|
|
18
|
+
`${counts.open} open`,
|
|
19
|
+
...(counts.completed ? [`${counts.completed} completed`] : []),
|
|
20
|
+
...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []),
|
|
21
|
+
].join(" · ");
|
|
22
|
+
|
|
23
|
+
return [`Todo: ${summary}`, ...formatTodoTaskLines(state)].join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatTodoTaskLines(state: TodoState | undefined): string[] {
|
|
27
|
+
if (!state || state.phases.every((phase) => phase.tasks.length === 0)) return [];
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
for (const phase of state.phases) {
|
|
30
|
+
if (phase.tasks.length === 0) continue;
|
|
31
|
+
lines.push(`${phase.name}:`);
|
|
32
|
+
lines.push(...phase.tasks.map((task) => ` ${taskMarker(task)} ${task.name}`));
|
|
33
|
+
}
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function countTodos(state: TodoState | readonly Todo[] | undefined): TodoCounts {
|
|
38
|
+
const tasks = Array.isArray(state) ? state : todoTasks(state as TodoState | undefined);
|
|
39
|
+
let open = 0;
|
|
40
|
+
let completed = 0;
|
|
41
|
+
let cancelled = 0;
|
|
42
|
+
for (const task of tasks) {
|
|
43
|
+
if (task.status === "completed") completed++;
|
|
44
|
+
else if (task.status === "cancelled") cancelled++;
|
|
45
|
+
else open++;
|
|
46
|
+
}
|
|
47
|
+
return { open, completed, cancelled };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function taskMarker(task: Pick<Todo, "status">): string {
|
|
51
|
+
switch (task.status) {
|
|
52
|
+
case "completed": return "✓";
|
|
53
|
+
case "in_progress": return "▶";
|
|
54
|
+
case "cancelled": return "×";
|
|
55
|
+
default: return "○";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function todoTasks(state: TodoState | undefined): PhasedTodo[] {
|
|
60
|
+
return state?.phases.flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name }))) ?? [];
|
|
61
|
+
}
|
package/src/glyphs.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TodoStatus } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type TodoGlyphs = Record<TodoStatus, string>;
|
|
4
|
+
|
|
5
|
+
export const NERD_FONT_TODO_GLYPHS: TodoGlyphs = {
|
|
6
|
+
pending: "",
|
|
7
|
+
in_progress: "",
|
|
8
|
+
completed: "",
|
|
9
|
+
cancelled: "",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const FALLBACK_TODO_GLYPHS: TodoGlyphs = {
|
|
13
|
+
pending: "○",
|
|
14
|
+
in_progress: "▶",
|
|
15
|
+
completed: "✓",
|
|
16
|
+
cancelled: "×",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function todoGlyph(status: TodoStatus, fallbackGlyphs = false): string {
|
|
20
|
+
return (fallbackGlyphs ? FALLBACK_TODO_GLYPHS : NERD_FONT_TODO_GLYPHS)[status];
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { TODO_ACTIONS, TODO_STATUSES, type TodoState, type TodoToolDetails } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const TODO_TOOL_NAME = "todo";
|
|
4
|
+
|
|
5
|
+
export const createEmptyTodoState = (): TodoState => ({ phases: [] });
|
|
6
|
+
|
|
7
|
+
export function cloneTodoState(state: TodoState): TodoState {
|
|
8
|
+
return structuredClone(state);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type BranchContext = {
|
|
12
|
+
sessionManager: {
|
|
13
|
+
getBranch(): readonly unknown[];
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ToolResultEntry = {
|
|
18
|
+
type: "message";
|
|
19
|
+
message: {
|
|
20
|
+
role: "toolResult";
|
|
21
|
+
toolName?: unknown;
|
|
22
|
+
isError?: unknown;
|
|
23
|
+
details?: unknown;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isTodoState(value: unknown): value is TodoState {
|
|
28
|
+
if (typeof value !== "object" || value === null) return false;
|
|
29
|
+
const state = value as { phases?: unknown };
|
|
30
|
+
if (!Array.isArray(state.phases)) return false;
|
|
31
|
+
|
|
32
|
+
const phaseNames = new Set<string>();
|
|
33
|
+
let activePhase: string | undefined;
|
|
34
|
+
for (const value of state.phases) {
|
|
35
|
+
if (typeof value !== "object" || value === null) return false;
|
|
36
|
+
const phase = value as { name?: unknown; tasks?: unknown };
|
|
37
|
+
if (!validName(phase.name) || phaseNames.has(phase.name) || !Array.isArray(phase.tasks)) return false;
|
|
38
|
+
phaseNames.add(phase.name);
|
|
39
|
+
|
|
40
|
+
const taskNames = new Set<string>();
|
|
41
|
+
for (const value of phase.tasks) {
|
|
42
|
+
if (typeof value !== "object" || value === null) return false;
|
|
43
|
+
const task = value as { name?: unknown; status?: unknown };
|
|
44
|
+
if (!validName(task.name) || taskNames.has(task.name) || !(TODO_STATUSES as readonly unknown[]).includes(task.status)) return false;
|
|
45
|
+
if (task.status === "in_progress") {
|
|
46
|
+
if (activePhase !== undefined && activePhase !== phase.name) return false;
|
|
47
|
+
activePhase = phase.name;
|
|
48
|
+
}
|
|
49
|
+
taskNames.add(task.name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validName(value: unknown): value is string {
|
|
56
|
+
return typeof value === "string" && value !== "" && value === value.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isTodoToolDetails(value: unknown): value is TodoToolDetails {
|
|
60
|
+
if (typeof value !== "object" || value === null) return false;
|
|
61
|
+
const details = value as { action?: unknown; state?: unknown };
|
|
62
|
+
return typeof details.action === "string"
|
|
63
|
+
&& (TODO_ACTIONS as readonly string[]).includes(details.action)
|
|
64
|
+
&& isTodoState(details.state);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isSuccessfulTodoResult(value: unknown): value is ToolResultEntry {
|
|
68
|
+
if (typeof value !== "object" || value === null) return false;
|
|
69
|
+
const entry = value as Partial<ToolResultEntry>;
|
|
70
|
+
return entry.type === "message"
|
|
71
|
+
&& typeof entry.message === "object"
|
|
72
|
+
&& entry.message !== null
|
|
73
|
+
&& entry.message.role === "toolResult"
|
|
74
|
+
&& entry.message.toolName === TODO_TOOL_NAME
|
|
75
|
+
&& entry.message.isError !== true
|
|
76
|
+
&& isTodoToolDetails(entry.message.details);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Restores the latest successful todo snapshot from the current session branch. */
|
|
80
|
+
export function restoreTodoState(ctx: BranchContext): TodoState {
|
|
81
|
+
const branch = ctx.sessionManager.getBranch();
|
|
82
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
83
|
+
const entry = branch[index];
|
|
84
|
+
if (isSuccessfulTodoResult(entry)) return cloneTodoState((entry.message.details as TodoToolDetails).state);
|
|
85
|
+
}
|
|
86
|
+
return createEmptyTodoState();
|
|
87
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { countTodos, formatTodoSummary, todoTasks, type PhasedTodo } from "./format.js";
|
|
5
|
+
import { todoGlyph } from "./glyphs.js";
|
|
6
|
+
import type { TodoParams } from "./schema.js";
|
|
7
|
+
import { todoAddressKey } from "./state.js";
|
|
8
|
+
import type { Todo, TodoAddress, TodoPhase, TodoState, TodoToolDetails } from "./types.js";
|
|
9
|
+
|
|
10
|
+
type ThemeLike = Partial<Pick<Theme, "fg" | "bold" | "strikethrough">>;
|
|
11
|
+
type ThemeColor = Parameters<Theme["fg"]>[0];
|
|
12
|
+
|
|
13
|
+
export type TodoRendererOptions = { fallbackGlyphs?: boolean };
|
|
14
|
+
|
|
15
|
+
export function renderCall(params: TodoParams | undefined, theme?: ThemeLike): Text {
|
|
16
|
+
const action = params?.action;
|
|
17
|
+
const label = typeof action === "string" && action ? `todo ${action}` : "todo";
|
|
18
|
+
return new Text(paint(theme, "toolTitle", label), 0, 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderResult(
|
|
22
|
+
result: { details?: TodoToolDetails; content?: readonly { type?: string; text?: string }[] },
|
|
23
|
+
options: { expanded?: boolean } = {},
|
|
24
|
+
theme?: ThemeLike,
|
|
25
|
+
rendererOptions: TodoRendererOptions = {},
|
|
26
|
+
): Text {
|
|
27
|
+
const state = result.details?.state;
|
|
28
|
+
if (!state) return new Text(fallbackText(result), 0, 0);
|
|
29
|
+
|
|
30
|
+
const tasks = todoTasks(state);
|
|
31
|
+
if (tasks.length === 0 && state.phases.length === 0) return new Text(paint(theme, "muted", "No todo tasks."), 0, 0);
|
|
32
|
+
|
|
33
|
+
const counts = countTodos(state);
|
|
34
|
+
const header = todoHeader(counts);
|
|
35
|
+
if (options.expanded !== true) return new Text(collapsedText(header, tasks, theme, rendererOptions), 0, 0);
|
|
36
|
+
|
|
37
|
+
const changed = new Set((result.details?.changedTasks ?? []).map(addressKey));
|
|
38
|
+
const selectedPhase = selectedPhaseIndex(state.phases);
|
|
39
|
+
const lines = [toolTitle(todoSummary(tasks), theme)];
|
|
40
|
+
for (const [index, phase] of state.phases.entries()) {
|
|
41
|
+
const heading = ` ${index + 1}. ${phaseSummary(phase)}`;
|
|
42
|
+
lines.push(index === selectedPhase ? toolTitle(heading, theme) : paint(theme, "dim", heading));
|
|
43
|
+
for (const task of orderedTasks(phase.tasks)) {
|
|
44
|
+
lines.push(renderTask(phase.name, task, changed, theme, rendererOptions));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatResultText(details: TodoToolDetails | undefined): string {
|
|
51
|
+
return formatTodoSummary(details?.state);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function todoHeader(counts: ReturnType<typeof countTodos>, title = "Todo"): string {
|
|
55
|
+
return [
|
|
56
|
+
`${title} · ${counts.open} open`,
|
|
57
|
+
...(counts.completed ? [`${counts.completed} completed`] : []),
|
|
58
|
+
...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []),
|
|
59
|
+
].join(" · ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collapsedText(header: string, tasks: PhasedTodo[], theme: ThemeLike | undefined, options: TodoRendererOptions): string {
|
|
63
|
+
const active = tasks.find((task) => task.status === "in_progress");
|
|
64
|
+
const activeText = active ? `Active: ${todoGlyph(active.status, options.fallbackGlyphs)} ${active.name}` : undefined;
|
|
65
|
+
return [paint(theme, "muted", header), ...(activeText ? [paint(theme, "warning", activeText)] : []), paint(theme, "dim", "↵ expand")].join(" · ");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function todoSummary(tasks: Todo[]): string {
|
|
69
|
+
const active = tasks.filter((task) => task.status === "in_progress").length;
|
|
70
|
+
const pending = tasks.filter((task) => task.status === "pending").length;
|
|
71
|
+
const completed = tasks.filter((task) => task.status === "completed").length;
|
|
72
|
+
const cancelled = tasks.filter((task) => task.status === "cancelled").length;
|
|
73
|
+
return [
|
|
74
|
+
"Todos",
|
|
75
|
+
...(active ? [`${active} active`] : []),
|
|
76
|
+
...(pending ? [`${pending} pending`] : []),
|
|
77
|
+
...(completed ? [`${completed} completed`] : []),
|
|
78
|
+
...(cancelled ? [`${cancelled} cancelled`] : []),
|
|
79
|
+
].join(" · ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function phaseSummary(phase: TodoPhase): string {
|
|
83
|
+
const active = phase.tasks.filter((task) => task.status === "in_progress").length;
|
|
84
|
+
const pending = phase.tasks.filter((task) => task.status === "pending").length;
|
|
85
|
+
const completed = phase.tasks.filter((task) => task.status === "completed").length;
|
|
86
|
+
const cancelled = phase.tasks.filter((task) => task.status === "cancelled").length;
|
|
87
|
+
return [
|
|
88
|
+
phase.name,
|
|
89
|
+
...(active ? [`${active} active`] : []),
|
|
90
|
+
...(pending ? [`${pending} pending`] : []),
|
|
91
|
+
...(completed ? [`${completed} completed`] : []),
|
|
92
|
+
...(cancelled ? [`${cancelled} cancelled`] : []),
|
|
93
|
+
].join(" · ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function selectedPhaseIndex(phases: TodoPhase[]): number {
|
|
97
|
+
const active = phases.findIndex((phase) => phase.tasks.some((task) => task.status === "in_progress"));
|
|
98
|
+
if (active !== -1) return active;
|
|
99
|
+
return phases.findIndex((phase) => phase.tasks.some((task) => task.status === "pending"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function orderedTasks(tasks: Todo[]): Todo[] {
|
|
103
|
+
return tasks
|
|
104
|
+
.map((task, index) => ({ task, index }))
|
|
105
|
+
.sort((left, right) => taskPriority(left.task) - taskPriority(right.task) || left.index - right.index)
|
|
106
|
+
.map(({ task }) => task);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function taskPriority(task: Todo): number {
|
|
110
|
+
if (task.status === "in_progress") return 0;
|
|
111
|
+
if (task.status === "pending") return 1;
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderTask(phase: string, task: Todo, changed: Set<string>, theme: ThemeLike | undefined, options: TodoRendererOptions): string {
|
|
116
|
+
const text = isTerminal(task) && theme?.strikethrough ? theme.strikethrough(task.name) : task.name;
|
|
117
|
+
let line = ` ${todoGlyph(task.status, options.fallbackGlyphs)} ${text}`;
|
|
118
|
+
if ((task.status === "in_progress" || changed.has(todoAddressKey(phase, task.name))) && theme?.bold) line = theme.bold(line);
|
|
119
|
+
return paint(theme, statusColor(task.status), line);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isTerminal(task: Todo): boolean {
|
|
123
|
+
return task.status === "completed" || task.status === "cancelled";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function addressKey(address: TodoAddress): string {
|
|
127
|
+
return todoAddressKey(address.phase, address.task);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function statusColor(status: string): ThemeColor {
|
|
131
|
+
if (status === "completed") return "success";
|
|
132
|
+
if (status === "in_progress") return "text";
|
|
133
|
+
return "dim";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toolTitle(text: string, theme: ThemeLike | undefined): string {
|
|
137
|
+
const title = theme?.bold ? theme.bold(text) : text;
|
|
138
|
+
return paint(theme, "toolTitle", title);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function paint(theme: ThemeLike | undefined, color: ThemeColor, text: string): string {
|
|
142
|
+
return theme?.fg ? theme.fg(color, text) : text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fallbackText(result: { content?: readonly { type?: string; text?: string }[] }): string {
|
|
146
|
+
return result.content?.find((part) => part.type === "text")?.text || "No todo tasks.";
|
|
147
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { TODO_ACTIONS, TODO_STATUSES, type TodoActionName, type TodoPhaseInput, type TodoTransitionInput } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export const TodoPhaseSchema = Type.Object({
|
|
6
|
+
name: Type.String({ minLength: 1, description: "Immutable phase name, 1 or 2 words, unique." }),
|
|
7
|
+
tasks: Type.Array(Type.String({
|
|
8
|
+
minLength: 1,
|
|
9
|
+
description: "Immutable task name, ideally 5–10 words, what not how, unique within its phase.",
|
|
10
|
+
})),
|
|
11
|
+
}, { additionalProperties: false });
|
|
12
|
+
|
|
13
|
+
export const TodoTransitionSchema = Type.Object({
|
|
14
|
+
phase: Type.String({ minLength: 1, description: "Exact immutable phase name." }),
|
|
15
|
+
task: Type.String({ minLength: 1, description: "Exact immutable task name within the phase." }),
|
|
16
|
+
status: StringEnum(TODO_STATUSES, { description: "New task status." }),
|
|
17
|
+
}, { additionalProperties: false });
|
|
18
|
+
|
|
19
|
+
/** Flat provider-facing schema. Action-specific requirements are enforced by the transition. */
|
|
20
|
+
export const TodoParamsSchema = Type.Object({
|
|
21
|
+
action: StringEnum(TODO_ACTIONS),
|
|
22
|
+
phases: Type.Optional(Type.Array(TodoPhaseSchema)),
|
|
23
|
+
transitions: Type.Optional(Type.Array(TodoTransitionSchema, { minItems: 1 })),
|
|
24
|
+
phase: Type.Optional(Type.String({ minLength: 1, description: "Optional exact phase name used to filter view." })),
|
|
25
|
+
}, { additionalProperties: false });
|
|
26
|
+
|
|
27
|
+
/** Broad parameter view used by tool render hooks. */
|
|
28
|
+
export type TodoParams = {
|
|
29
|
+
action: TodoActionName;
|
|
30
|
+
phases?: TodoPhaseInput[];
|
|
31
|
+
transitions?: TodoTransitionInput[];
|
|
32
|
+
phase?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const TodoSchema = TodoParamsSchema;
|