@pi9/todo 0.1.0 → 0.2.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/README.md +11 -70
- package/package.json +2 -2
- package/src/format.ts +51 -17
- package/src/persistence.ts +30 -59
- package/src/reminder-cadence.ts +59 -0
- package/src/reminder.ts +24 -0
- package/src/renderer.ts +9 -55
- package/src/schema.ts +6 -16
- package/src/settings.ts +85 -66
- package/src/state.ts +132 -58
- package/src/tool.ts +100 -33
- package/src/types.ts +29 -22
- package/src/visibility.ts +1 -5
- package/src/widget-component.ts +13 -6
- package/src/widget-layout.ts +11 -52
- package/src/widget.ts +11 -18
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @pi9/todo
|
|
2
2
|
|
|
3
|
-
A phased,
|
|
3
|
+
A phased, session-aware todo tool for the [Pi coding agent](https://github.com/earendil-works/pi-mono).
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -15,74 +15,6 @@ A phased, branch-aware todo tool for the [Pi coding agent](https://github.com/ea
|
|
|
15
15
|
|
|
16
16
|
Todo snapshots are stored in tool-result details, so `/tree` navigation restores the plan associated with that branch.
|
|
17
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
18
|
## Install
|
|
87
19
|
|
|
88
20
|
```bash
|
|
@@ -104,7 +36,12 @@ The settings loader reads global settings from `~/.pi/agent/todo/settings.json`.
|
|
|
104
36
|
"widgetPlacement": "aboveEditor",
|
|
105
37
|
"maxVisibleTasks": 5,
|
|
106
38
|
"fallbackGlyphs": false,
|
|
107
|
-
"toolVisibility": "set-only"
|
|
39
|
+
"toolVisibility": "set-only",
|
|
40
|
+
"dynamicReminders": true,
|
|
41
|
+
"reminderMinTurns": 4,
|
|
42
|
+
"reminderMaxTurns": 8,
|
|
43
|
+
"reminderOutputTokens": 16000,
|
|
44
|
+
"reminderMaxPerRun": 2
|
|
108
45
|
}
|
|
109
46
|
```
|
|
110
47
|
|
|
@@ -118,6 +55,10 @@ The settings loader reads global settings from `~/.pi/agent/todo/settings.json`.
|
|
|
118
55
|
|
|
119
56
|
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
57
|
|
|
58
|
+
Dynamic reminders are transient user-role context messages: they are supplied to the model only for the current request and are never added to session history. A reminder is due only after `reminderMinTurns`, then when either `reminderMaxTurns` or `reminderOutputTokens` is reached, up to `reminderMaxPerRun` times per agent run. This guarded-OR cadence prevents reminders during short bursts while still catching either many small turns or a few output-heavy turns. Output tokens are counted because model-generated work, rather than prompt size, is the useful signal that a plan may have become stale. Any successful Todo action, including `view`, resets the turn/token window at the end of that turn; failed actions do not. Set `dynamicReminders` to `false` to disable reminders.
|
|
59
|
+
|
|
60
|
+
After a successful manual, threshold, or overflow Pi compaction, the extension injects a one-shot transient full phased plan into the next model context build. It includes every phase and task with literal statuses, including `completed` and `cancelled` tasks and terminal-only plans; plans with zero tasks are skipped. This does not change Pi's compaction summary or session history. The injection takes priority over a due dynamic cadence reminder and resets its staleness window, regardless of `dynamicReminders`. Its pending state is in memory only and is cleared on session start/reload or `/tree` navigation, so it is not persisted across an extension reload before delivery.
|
|
61
|
+
|
|
121
62
|
Settings load when a session starts. The widget refreshes after todo changes and `/tree` navigation. Set `widgetPlacement` to `"off"` to disable it.
|
|
122
63
|
|
|
123
64
|
## Development
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi9/todo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Phased, session-aware todo planning for Pi agents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Chase Cummings <chaseecummings@gmail.com>",
|
|
7
7
|
"type": "module",
|
package/src/format.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type { Todo, TodoState } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export type PhasedTodo = Todo & { phase: string };
|
|
1
|
+
import type { Todo, TodoState, TodoStatus } from "./types.js";
|
|
4
2
|
|
|
5
3
|
export interface TodoCounts {
|
|
6
4
|
open: number;
|
|
@@ -8,6 +6,8 @@ export interface TodoCounts {
|
|
|
8
6
|
cancelled: number;
|
|
9
7
|
}
|
|
10
8
|
|
|
9
|
+
export type TodoStatusCounts = Record<TodoStatus, number>;
|
|
10
|
+
|
|
11
11
|
/** A small, plain-text representation used in tool results and model context. */
|
|
12
12
|
export function formatTodoSummary(state: TodoState | undefined): string {
|
|
13
13
|
const tasks = todoTasks(state);
|
|
@@ -24,7 +24,8 @@ export function formatTodoSummary(state: TodoState | undefined): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function formatTodoTaskLines(state: TodoState | undefined): string[] {
|
|
27
|
-
if (!state
|
|
27
|
+
if (!state) return [];
|
|
28
|
+
|
|
28
29
|
const lines: string[] = [];
|
|
29
30
|
for (const phase of state.phases) {
|
|
30
31
|
if (phase.tasks.length === 0) continue;
|
|
@@ -34,17 +35,50 @@ export function formatTodoTaskLines(state: TodoState | undefined): string[] {
|
|
|
34
35
|
return lines;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
38
|
+
/** Formats the complete one-shot Todo snapshot injected after compaction. */
|
|
39
|
+
export function formatTodoCompactionContext(state: TodoState): string | undefined {
|
|
40
|
+
if (!state.phases.some((phase) => phase.tasks.length > 0)) return undefined;
|
|
41
|
+
|
|
42
|
+
const plan = state.phases.flatMap((phase) => [
|
|
43
|
+
`${phase.name}:`,
|
|
44
|
+
...(phase.tasks.length === 0
|
|
45
|
+
? [" (no tasks)"]
|
|
46
|
+
: phase.tasks.map((task) => ` [${task.status}] ${task.name}`)),
|
|
47
|
+
]);
|
|
48
|
+
return [
|
|
49
|
+
"<system-reminder source=\"todo-post-compaction\">",
|
|
50
|
+
"Todo plan after compaction:",
|
|
51
|
+
...plan,
|
|
52
|
+
"Continue using this plan and keep task statuses current.",
|
|
53
|
+
"Do not mention this reminder to the user.",
|
|
54
|
+
"</system-reminder>",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function countTodoStatuses(tasks: readonly Todo[]): TodoStatusCounts {
|
|
59
|
+
const counts: TodoStatusCounts = { pending: 0, in_progress: 0, completed: 0, cancelled: 0 };
|
|
60
|
+
for (const task of tasks) counts[task.status] += 1;
|
|
61
|
+
return counts;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function countTodos(tasks: readonly Todo[]): TodoCounts {
|
|
65
|
+
const counts = countTodoStatuses(tasks);
|
|
66
|
+
return {
|
|
67
|
+
open: counts.pending + counts.in_progress,
|
|
68
|
+
completed: counts.completed,
|
|
69
|
+
cancelled: counts.cancelled,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatTodoProgress(label: string, tasks: readonly Todo[]): string {
|
|
74
|
+
const counts = countTodoStatuses(tasks);
|
|
75
|
+
return [
|
|
76
|
+
label,
|
|
77
|
+
...(counts.in_progress ? [`${counts.in_progress} active`] : []),
|
|
78
|
+
...(counts.pending ? [`${counts.pending} pending`] : []),
|
|
79
|
+
...(counts.completed ? [`${counts.completed} completed`] : []),
|
|
80
|
+
...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []),
|
|
81
|
+
].join(" · ");
|
|
48
82
|
}
|
|
49
83
|
|
|
50
84
|
export function taskMarker(task: Pick<Todo, "status">): string {
|
|
@@ -56,6 +90,6 @@ export function taskMarker(task: Pick<Todo, "status">): string {
|
|
|
56
90
|
}
|
|
57
91
|
}
|
|
58
92
|
|
|
59
|
-
export function todoTasks(state: TodoState | undefined):
|
|
60
|
-
return state?.phases.flatMap((phase) => phase.tasks
|
|
93
|
+
export function todoTasks(state: TodoState | undefined): readonly Todo[] {
|
|
94
|
+
return state?.phases.flatMap((phase) => phase.tasks) ?? [];
|
|
61
95
|
}
|
package/src/persistence.ts
CHANGED
|
@@ -1,79 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cloneTodoState, createTodoState, isTodoState } from "./state.js";
|
|
2
|
+
import { isTodoActionName, type TodoActionName, type TodoState } from "./types.js";
|
|
2
3
|
|
|
3
4
|
export const TODO_TOOL_NAME = "todo";
|
|
4
5
|
|
|
5
|
-
export const createEmptyTodoState = (): TodoState => ({ phases: [] });
|
|
6
|
-
|
|
7
|
-
export function cloneTodoState(state: TodoState): TodoState {
|
|
8
|
-
return structuredClone(state);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
6
|
type BranchContext = {
|
|
12
7
|
sessionManager: {
|
|
13
8
|
getBranch(): readonly unknown[];
|
|
14
9
|
};
|
|
15
10
|
};
|
|
16
11
|
|
|
17
|
-
type
|
|
12
|
+
type TodoSnapshotDetails = {
|
|
13
|
+
action: TodoActionName;
|
|
14
|
+
state: TodoState;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type TodoResultEntry = {
|
|
18
18
|
type: "message";
|
|
19
19
|
message: {
|
|
20
20
|
role: "toolResult";
|
|
21
|
-
toolName
|
|
21
|
+
toolName: typeof TODO_TOOL_NAME;
|
|
22
22
|
isError?: unknown;
|
|
23
|
-
details
|
|
23
|
+
details: TodoSnapshotDetails;
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
function
|
|
28
|
-
if (typeof value !== "object"
|
|
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;
|
|
27
|
+
function isTodoSnapshotDetails(value: unknown): value is TodoSnapshotDetails {
|
|
28
|
+
if (!value || typeof value !== "object") return false;
|
|
61
29
|
const details = value as { action?: unknown; state?: unknown };
|
|
62
|
-
return
|
|
63
|
-
&& (TODO_ACTIONS as readonly string[]).includes(details.action)
|
|
64
|
-
&& isTodoState(details.state);
|
|
30
|
+
return isTodoActionName(details.action) && isTodoState(details.state);
|
|
65
31
|
}
|
|
66
32
|
|
|
67
|
-
function isSuccessfulTodoResult(value: unknown): value is
|
|
68
|
-
if (typeof value !== "object"
|
|
69
|
-
const entry = value as
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
33
|
+
function isSuccessfulTodoResult(value: unknown): value is TodoResultEntry {
|
|
34
|
+
if (!value || typeof value !== "object") return false;
|
|
35
|
+
const entry = value as { type?: unknown; message?: unknown };
|
|
36
|
+
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return false;
|
|
37
|
+
|
|
38
|
+
const message = entry.message as {
|
|
39
|
+
role?: unknown;
|
|
40
|
+
toolName?: unknown;
|
|
41
|
+
isError?: unknown;
|
|
42
|
+
details?: unknown;
|
|
43
|
+
};
|
|
44
|
+
return message.role === "toolResult"
|
|
45
|
+
&& message.toolName === TODO_TOOL_NAME
|
|
46
|
+
&& message.isError !== true
|
|
47
|
+
&& isTodoSnapshotDetails(message.details);
|
|
77
48
|
}
|
|
78
49
|
|
|
79
50
|
/** Restores the latest successful todo snapshot from the current session branch. */
|
|
@@ -81,7 +52,7 @@ export function restoreTodoState(ctx: BranchContext): TodoState {
|
|
|
81
52
|
const branch = ctx.sessionManager.getBranch();
|
|
82
53
|
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
83
54
|
const entry = branch[index];
|
|
84
|
-
if (isSuccessfulTodoResult(entry)) return cloneTodoState(
|
|
55
|
+
if (isSuccessfulTodoResult(entry)) return cloneTodoState(entry.message.details.state);
|
|
85
56
|
}
|
|
86
|
-
return
|
|
57
|
+
return createTodoState();
|
|
87
58
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface ReminderCadenceConfig {
|
|
2
|
+
readonly minTurns: number;
|
|
3
|
+
readonly maxTurns: number;
|
|
4
|
+
readonly outputTokens: number;
|
|
5
|
+
readonly maxPerRun: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ReminderCadenceState {
|
|
9
|
+
readonly turns: number;
|
|
10
|
+
readonly outputTokens: number;
|
|
11
|
+
readonly remindersThisRun: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createReminderCadenceState(): ReminderCadenceState {
|
|
15
|
+
return { turns: 0, outputTokens: 0, remindersThisRun: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function beginReminderAgentRun(state: ReminderCadenceState): ReminderCadenceState {
|
|
19
|
+
return { ...state, remindersThisRun: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function noteReminderTurn(
|
|
23
|
+
state: ReminderCadenceState,
|
|
24
|
+
outputTokens: number = 0,
|
|
25
|
+
): ReminderCadenceState {
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
turns: state.turns + 1,
|
|
29
|
+
outputTokens: state.outputTokens + validTokenCount(outputTokens),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function noteTodoInteraction(state: ReminderCadenceState): ReminderCadenceState {
|
|
34
|
+
return { ...state, turns: 0, outputTokens: 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function consumeDueReminder(
|
|
38
|
+
state: ReminderCadenceState,
|
|
39
|
+
config: ReminderCadenceConfig,
|
|
40
|
+
): { due: boolean; state: ReminderCadenceState } {
|
|
41
|
+
const minimumMet = state.turns >= config.minTurns;
|
|
42
|
+
const triggerMet = state.turns >= config.maxTurns || state.outputTokens >= config.outputTokens;
|
|
43
|
+
const belowCap = state.remindersThisRun < config.maxPerRun;
|
|
44
|
+
|
|
45
|
+
if (!minimumMet || !triggerMet || !belowCap) return { due: false, state };
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
due: true,
|
|
49
|
+
state: {
|
|
50
|
+
turns: 0,
|
|
51
|
+
outputTokens: 0,
|
|
52
|
+
remindersThisRun: state.remindersThisRun + 1,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validTokenCount(value: number): number {
|
|
58
|
+
return Number.isFinite(value) && value >= 0 ? value : 0;
|
|
59
|
+
}
|
package/src/reminder.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { countTodoStatuses, todoTasks } from "./format.js";
|
|
2
|
+
import { currentTodoPhaseIndex } from "./state.js";
|
|
3
|
+
import type { TodoState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** Formats the transient model-context reminder for an unfinished todo plan. */
|
|
6
|
+
export function formatTodoReminder(state: TodoState): string | undefined {
|
|
7
|
+
const activePhase = state.phases[currentTodoPhaseIndex(state.phases)];
|
|
8
|
+
if (!activePhase) return undefined;
|
|
9
|
+
|
|
10
|
+
const activeTasks = activePhase.tasks
|
|
11
|
+
.filter((task) => task.status === "in_progress")
|
|
12
|
+
.map((task) => task.name);
|
|
13
|
+
const counts = countTodoStatuses(todoTasks(state));
|
|
14
|
+
|
|
15
|
+
return [
|
|
16
|
+
"<system-reminder>",
|
|
17
|
+
`Active phase: ${activePhase.name}`,
|
|
18
|
+
activeTasks.length > 0 ? `In progress: ${activeTasks.join("; ")}` : "No task is in_progress.",
|
|
19
|
+
`Counts: ${counts.in_progress} in_progress, ${counts.pending} pending, ${counts.completed} completed, ${counts.cancelled} cancelled.`,
|
|
20
|
+
"Review and update the todo if task status has changed.",
|
|
21
|
+
"Do not mention this reminder to the user.",
|
|
22
|
+
"</system-reminder>",
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
-
import { countTodos,
|
|
4
|
+
import { countTodos, formatTodoProgress, todoTasks } from "./format.js";
|
|
5
5
|
import { todoGlyph } from "./glyphs.js";
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import type { Todo, TodoAddress, TodoPhase, TodoState, TodoToolDetails } from "./types.js";
|
|
6
|
+
import { currentTodoPhaseIndex, todoAddressKey } from "./state.js";
|
|
7
|
+
import type { Todo, TodoAddress, TodoToolDetails } from "./types.js";
|
|
9
8
|
|
|
10
9
|
type ThemeLike = Partial<Pick<Theme, "fg" | "bold" | "strikethrough">>;
|
|
11
10
|
type ThemeColor = Parameters<Theme["fg"]>[0];
|
|
12
11
|
|
|
13
12
|
export type TodoRendererOptions = { fallbackGlyphs?: boolean };
|
|
14
13
|
|
|
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
14
|
export function renderResult(
|
|
22
15
|
result: { details?: TodoToolDetails; content?: readonly { type?: string; text?: string }[] },
|
|
23
16
|
options: { expanded?: boolean } = {},
|
|
@@ -30,15 +23,14 @@ export function renderResult(
|
|
|
30
23
|
const tasks = todoTasks(state);
|
|
31
24
|
if (tasks.length === 0 && state.phases.length === 0) return new Text(paint(theme, "muted", "No todo tasks."), 0, 0);
|
|
32
25
|
|
|
33
|
-
const
|
|
34
|
-
const header = todoHeader(counts);
|
|
26
|
+
const header = todoHeader(countTodos(tasks));
|
|
35
27
|
if (options.expanded !== true) return new Text(collapsedText(header, tasks, theme, rendererOptions), 0, 0);
|
|
36
28
|
|
|
37
29
|
const changed = new Set((result.details?.changedTasks ?? []).map(addressKey));
|
|
38
|
-
const selectedPhase =
|
|
39
|
-
const lines = [toolTitle(
|
|
30
|
+
const selectedPhase = currentTodoPhaseIndex(state.phases);
|
|
31
|
+
const lines = [toolTitle(formatTodoProgress("Todos", tasks), theme)];
|
|
40
32
|
for (const [index, phase] of state.phases.entries()) {
|
|
41
|
-
const heading = ` ${index + 1}. ${
|
|
33
|
+
const heading = ` ${index + 1}. ${formatTodoProgress(phase.name, phase.tasks)}`;
|
|
42
34
|
lines.push(index === selectedPhase ? toolTitle(heading, theme) : paint(theme, "dim", heading));
|
|
43
35
|
for (const task of orderedTasks(phase.tasks)) {
|
|
44
36
|
lines.push(renderTask(phase.name, task, changed, theme, rendererOptions));
|
|
@@ -47,10 +39,6 @@ export function renderResult(
|
|
|
47
39
|
return new Text(lines.join("\n"), 0, 0);
|
|
48
40
|
}
|
|
49
41
|
|
|
50
|
-
export function formatResultText(details: TodoToolDetails | undefined): string {
|
|
51
|
-
return formatTodoSummary(details?.state);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
42
|
function todoHeader(counts: ReturnType<typeof countTodos>, title = "Todo"): string {
|
|
55
43
|
return [
|
|
56
44
|
`${title} · ${counts.open} open`,
|
|
@@ -59,47 +47,13 @@ function todoHeader(counts: ReturnType<typeof countTodos>, title = "Todo"): stri
|
|
|
59
47
|
].join(" · ");
|
|
60
48
|
}
|
|
61
49
|
|
|
62
|
-
function collapsedText(header: string, tasks:
|
|
50
|
+
function collapsedText(header: string, tasks: readonly Todo[], theme: ThemeLike | undefined, options: TodoRendererOptions): string {
|
|
63
51
|
const active = tasks.find((task) => task.status === "in_progress");
|
|
64
52
|
const activeText = active ? `Active: ${todoGlyph(active.status, options.fallbackGlyphs)} ${active.name}` : undefined;
|
|
65
53
|
return [paint(theme, "muted", header), ...(activeText ? [paint(theme, "warning", activeText)] : []), paint(theme, "dim", "↵ expand")].join(" · ");
|
|
66
54
|
}
|
|
67
55
|
|
|
68
|
-
function
|
|
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[] {
|
|
56
|
+
function orderedTasks(tasks: readonly Todo[]): Todo[] {
|
|
103
57
|
return tasks
|
|
104
58
|
.map((task, index) => ({ task, index }))
|
|
105
59
|
.sort((left, right) => taskPriority(left.task) - taskPriority(right.task) || left.index - right.index)
|
package/src/schema.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { TODO_ACTIONS, TODO_STATUSES
|
|
3
|
+
import { TODO_ACTIONS, TODO_STATUSES } from "./types.js";
|
|
4
4
|
|
|
5
5
|
export const TodoPhaseSchema = Type.Object({
|
|
6
|
-
name: Type.String({ minLength: 1, description: "
|
|
6
|
+
name: Type.String({ minLength: 1, description: "Unique immutable phase name (1–2 words)." }),
|
|
7
7
|
tasks: Type.Array(Type.String({
|
|
8
8
|
minLength: 1,
|
|
9
|
-
description: "
|
|
9
|
+
description: "Unique immutable task name within its phase; ideally 5–10 words describing what, not how.",
|
|
10
10
|
})),
|
|
11
11
|
}, { additionalProperties: false });
|
|
12
12
|
|
|
13
13
|
export const TodoTransitionSchema = Type.Object({
|
|
14
|
-
phase: Type.String({ minLength: 1, description: "Exact
|
|
15
|
-
task: Type.String({ minLength: 1, description: "Exact
|
|
14
|
+
phase: Type.String({ minLength: 1, description: "Exact phase name." }),
|
|
15
|
+
task: Type.String({ minLength: 1, description: "Exact task name within the phase." }),
|
|
16
16
|
status: StringEnum(TODO_STATUSES, { description: "New task status." }),
|
|
17
17
|
}, { additionalProperties: false });
|
|
18
18
|
|
|
@@ -21,15 +21,5 @@ export const TodoParamsSchema = Type.Object({
|
|
|
21
21
|
action: StringEnum(TODO_ACTIONS),
|
|
22
22
|
phases: Type.Optional(Type.Array(TodoPhaseSchema)),
|
|
23
23
|
transitions: Type.Optional(Type.Array(TodoTransitionSchema, { minItems: 1 })),
|
|
24
|
-
phase: Type.Optional(Type.String({ minLength: 1, description: "
|
|
24
|
+
phase: Type.Optional(Type.String({ minLength: 1, description: "Exact phase to view; omit for the full plan." })),
|
|
25
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;
|