pi-ui-extend 0.1.13 → 0.1.17
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 +1 -1
- package/dist/app/app.d.ts +7 -0
- package/dist/app/app.js +102 -17
- package/dist/app/commands/command-controller.js +2 -0
- package/dist/app/commands/command-host.d.ts +5 -0
- package/dist/app/commands/command-model-actions.d.ts +2 -0
- package/dist/app/commands/command-model-actions.js +40 -4
- package/dist/app/commands/command-navigation-actions.d.ts +9 -0
- package/dist/app/commands/command-navigation-actions.js +62 -0
- package/dist/app/commands/command-registry.d.ts +2 -0
- package/dist/app/commands/command-registry.js +16 -0
- package/dist/app/constants.d.ts +0 -1
- package/dist/app/constants.js +0 -1
- package/dist/app/extensions/extension-ui-controller.d.ts +16 -5
- package/dist/app/extensions/extension-ui-controller.js +99 -61
- package/dist/app/icons.d.ts +1 -0
- package/dist/app/icons.js +2 -0
- package/dist/app/input/input-action-controller.d.ts +2 -0
- package/dist/app/input/input-action-controller.js +8 -1
- package/dist/app/logger.d.ts +25 -0
- package/dist/app/logger.js +90 -0
- package/dist/app/model/model-usage-status.js +30 -15
- package/dist/app/popup/menu-items-controller.d.ts +4 -0
- package/dist/app/popup/menu-items-controller.js +68 -6
- package/dist/app/popup/popup-action-controller.d.ts +2 -1
- package/dist/app/popup/popup-action-controller.js +7 -4
- package/dist/app/popup/popup-menu-controller.d.ts +36 -23
- package/dist/app/popup/popup-menu-controller.js +97 -326
- package/dist/app/rendering/conversation-entry-renderer.js +3 -3
- package/dist/app/rendering/conversation-viewport.d.ts +10 -2
- package/dist/app/rendering/conversation-viewport.js +157 -16
- package/dist/app/rendering/editor-panels.js +22 -9
- package/dist/app/rendering/popup-menu-renderer.d.ts +62 -0
- package/dist/app/rendering/popup-menu-renderer.js +405 -0
- package/dist/app/rendering/render-controller.js +30 -28
- package/dist/app/rendering/render-text.js +5 -2
- package/dist/app/rendering/status-line-renderer.d.ts +8 -1
- package/dist/app/rendering/status-line-renderer.js +217 -117
- package/dist/app/rendering/toast-controller.d.ts +12 -3
- package/dist/app/rendering/toast-controller.js +70 -12
- package/dist/app/runtime.d.ts +2 -1
- package/dist/app/runtime.js +20 -10
- package/dist/app/screen/mouse-controller.d.ts +2 -2
- package/dist/app/screen/mouse-controller.js +27 -48
- package/dist/app/screen/screen-styler.d.ts +1 -1
- package/dist/app/screen/screen-styler.js +9 -7
- package/dist/app/screen/scroll-controller.d.ts +12 -9
- package/dist/app/screen/scroll-controller.js +56 -45
- package/dist/app/screen/status-controller.js +2 -1
- package/dist/app/session/lazy-session-manager.d.ts +11 -0
- package/dist/app/session/lazy-session-manager.js +539 -0
- package/dist/app/session/pix-system-message.d.ts +16 -0
- package/dist/app/session/pix-system-message.js +64 -0
- package/dist/app/session/request-history.d.ts +4 -0
- package/dist/app/session/request-history.js +11 -0
- package/dist/app/session/session-event-controller.d.ts +11 -0
- package/dist/app/session/session-event-controller.js +58 -2
- package/dist/app/session/session-history.d.ts +18 -0
- package/dist/app/session/session-history.js +72 -3
- package/dist/app/session/session-lifecycle-controller.d.ts +6 -2
- package/dist/app/session/session-lifecycle-controller.js +7 -2
- package/dist/app/session/session-search.js +10 -0
- package/dist/app/session/tabs-controller.d.ts +17 -5
- package/dist/app/session/tabs-controller.js +308 -29
- package/dist/app/todo/todo-model.d.ts +4 -2
- package/dist/app/todo/todo-model.js +23 -13
- package/dist/app/types.d.ts +17 -6
- package/dist/app/workspace/workspace-actions-controller.d.ts +2 -0
- package/dist/app/workspace/workspace-actions-controller.js +12 -0
- package/dist/config.d.ts +6 -1
- package/dist/config.js +82 -25
- package/dist/default-pix-config.js +4 -0
- package/dist/fuzzy.d.ts +2 -0
- package/dist/fuzzy.js +27 -7
- package/dist/input-editor.d.ts +9 -0
- package/dist/input-editor.js +52 -0
- package/dist/schemas/pi-tools-suite-schema.d.ts +1 -0
- package/dist/schemas/pi-tools-suite-schema.js +1 -0
- package/dist/schemas/pix-schema.d.ts +3 -1
- package/dist/schemas/pix-schema.js +6 -4
- package/dist/terminal-width.d.ts +2 -0
- package/dist/terminal-width.js +64 -3
- package/dist/theme.js +6 -6
- package/dist/ui.d.ts +8 -0
- package/external/pi-tools-suite/README.md +3 -2
- package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +52 -8
- package/external/pi-tools-suite/src/antigravity-auth/commands.ts +3 -41
- package/external/pi-tools-suite/src/antigravity-auth/constants.ts +0 -2
- package/external/pi-tools-suite/src/antigravity-auth/index.ts +11 -18
- package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +129 -61
- package/external/pi-tools-suite/src/antigravity-auth/status.ts +82 -3
- package/external/pi-tools-suite/src/antigravity-auth/stream.ts +20 -7
- package/external/pi-tools-suite/src/antigravity-auth/types.ts +21 -0
- package/external/pi-tools-suite/src/config.ts +8 -0
- package/external/pi-tools-suite/src/dcp/index.ts +16 -1
- package/external/pi-tools-suite/src/dcp/state.ts +35 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +3 -0
- package/external/pi-tools-suite/src/todo/index.ts +123 -14
- package/external/pi-tools-suite/src/todo/state/persistence.ts +0 -1
- package/external/pi-tools-suite/src/todo/state/state-reducer.ts +26 -43
- package/external/pi-tools-suite/src/todo/todo.ts +12 -23
- package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +34 -16
- package/external/pi-tools-suite/src/todo/tool/types.ts +7 -28
- package/external/pi-tools-suite/src/todo/view/format.ts +2 -3
- package/external/pi-tools-suite/src/tool-descriptions.ts +6 -4
- package/external/pi-tools-suite/src/usage/index.ts +5 -2
- package/external/pi-tools-suite/src/usage/lib/google.ts +53 -40
- package/external/pi-tools-suite/src/usage/lib/types.ts +12 -2
- package/package.json +1 -1
- package/schemas/pi-tools-suite.json +4 -0
- package/schemas/pix.json +11 -2
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadPiToolsSuiteConfig } from "../config.js";
|
|
2
3
|
import { autoClearCompletedTodos } from "./state/auto-clear.js";
|
|
3
4
|
import { loadPersistedPlan, syncPersistedPlan } from "./state/persistence.js";
|
|
4
5
|
import { replayFromBranch } from "./state/replay.js";
|
|
5
6
|
import { ACTIVE_STATUSES, isTaskBlocked, selectVisibleTasks } from "./state/selectors.js";
|
|
6
7
|
import { getState, replaceState } from "./state/store.js";
|
|
7
|
-
import { publishTodoState, registerTodosCommand, registerTodoTool } from "./todo.js";
|
|
8
|
+
import { DEFAULT_PROMPT_GUIDELINES, DEFAULT_PROMPT_SNIPPET, publishTodoState, registerTodosCommand, registerTodoTool } from "./todo.js";
|
|
9
|
+
import type { TaskMutationParams } from "./tool/types.js";
|
|
8
10
|
|
|
9
11
|
const TODO_NUDGE_LIMIT = 8;
|
|
10
12
|
const TODO_NUDGE_INITIAL_DELAY_MS = 5_000;
|
|
11
13
|
const TODO_NUDGE_IDLE_RETRY_DELAY_MS = 100;
|
|
12
14
|
const TODO_NUDGE_MAX_IDLE_ATTEMPTS = 40;
|
|
13
15
|
const ASK_USER_TOOL_NAMES = new Set(["ask_user", "ask_user_question", "question"]);
|
|
16
|
+
const TODO_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
17
|
+
|
|
18
|
+
type TodoThinkingLevel = (typeof TODO_THINKING_LEVELS)[number];
|
|
19
|
+
type ModelLike = { reasoning?: boolean; thinkingLevelMap?: Partial<Record<TodoThinkingLevel, unknown | null>> };
|
|
20
|
+
|
|
21
|
+
function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel {
|
|
22
|
+
return TODO_THINKING_LEVELS.includes(value as TodoThinkingLevel);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getAvailableTodoThinkingLevels(model: unknown): TodoThinkingLevel[] {
|
|
26
|
+
const m = model as ModelLike | undefined;
|
|
27
|
+
if (!m?.reasoning) return ["off"];
|
|
28
|
+
const map = m.thinkingLevelMap;
|
|
29
|
+
return TODO_THINKING_LEVELS.filter((level) => level === "off" || map?.[level] !== null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildThinkingPromptParts(model: unknown): { promptSnippet?: string; promptGuidelines?: string[] } {
|
|
33
|
+
const levels = getAvailableTodoThinkingLevels(model);
|
|
34
|
+
if (levels.length <= 1) return {};
|
|
35
|
+
return {
|
|
36
|
+
promptSnippet: `${DEFAULT_PROMPT_SNIPPET} Optional per-item thinking: ${levels.join("|")}.`.trim(),
|
|
37
|
+
promptGuidelines: [
|
|
38
|
+
...DEFAULT_PROMPT_GUIDELINES,
|
|
39
|
+
`If todoThinking is enabled, assign task \`thinking\` during create/batch_create or update whenever planned items differ in complexity; choose from ${levels.join(", ")}. Use higher thinking for investigation, hard debugging, risky edits, or review; use lower/off for mechanical steps and the final user-facing report. Do not leave all thinking unset for a non-trivial mixed-complexity plan.`,
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
14
43
|
|
|
15
44
|
function isAskUserToolName(toolName: string): boolean {
|
|
16
45
|
return ASK_USER_TOOL_NAMES.has(toolName);
|
|
@@ -45,7 +74,7 @@ function getUnfinishedTodoNudge(): { signature: string; message: string } | unde
|
|
|
45
74
|
message: [
|
|
46
75
|
"Todo auto-nudge: unfinished todo items remain after your last response.",
|
|
47
76
|
"Continue working on them now. Pick exactly one pending/in_progress item, mark it in_progress if needed, make concrete progress, and update or complete todos immediately as work changes.",
|
|
48
|
-
"If the user added/removed/canceled requirements or changed goal/scope/
|
|
77
|
+
"If the user added/removed/canceled requirements or changed goal/scope/approach, or if discovered facts make the current plan stale/incomplete/impossible, synchronize todos first: update still-relevant items, defer/delete obsolete ones, add new tasks, and adjust blockers/order.",
|
|
49
78
|
"If progress is waiting on user-supplied data, clarification, or a decision, defer the affected plan/todos before your final response instead of leaving them pending/in_progress, so auto-nudge stops until the user replies.",
|
|
50
79
|
"For non-user blockers, leave the current item in_progress and create/update a blocker task instead of stopping.",
|
|
51
80
|
"",
|
|
@@ -59,9 +88,8 @@ function getPersistedPlanPrompt(path: string): string | undefined {
|
|
|
59
88
|
if (unfinished.length === 0) return undefined;
|
|
60
89
|
const lines = unfinished.slice(0, TODO_NUDGE_LIMIT).map((task) => {
|
|
61
90
|
const activeForm = task.activeForm ? ` — ${task.activeForm}` : "";
|
|
62
|
-
const priority = task.priority ? ` (${task.priority})` : "";
|
|
63
91
|
const blockedBy = task.blockedBy?.length ? ` (blocked by #${task.blockedBy.join(", #")})` : "";
|
|
64
|
-
return `- #${task.id} [${task.status}]
|
|
92
|
+
return `- #${task.id} [${task.status}] ${task.subject}${activeForm}${blockedBy}`;
|
|
65
93
|
});
|
|
66
94
|
if (unfinished.length > TODO_NUDGE_LIMIT) lines.push(`- …and ${unfinished.length - TODO_NUDGE_LIMIT} more unfinished todo item(s).`);
|
|
67
95
|
return [
|
|
@@ -84,9 +112,86 @@ function emitPersistedPlanPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt
|
|
|
84
112
|
}
|
|
85
113
|
|
|
86
114
|
export default function (pi: ExtensionAPI) {
|
|
115
|
+
let currentModel: unknown;
|
|
116
|
+
const todoThinkingEnabled = loadPiToolsSuiteConfig(["todo"]).todoThinking;
|
|
117
|
+
const rememberedThinkingByTaskId = new Map<number, TodoThinkingLevel>();
|
|
87
118
|
let lastNudgedSignature: string | undefined;
|
|
88
119
|
let nudgeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
89
120
|
const pendingAskUserToolCallIds = new Set<string>();
|
|
121
|
+
let suppressNextNudgeForThinkingSwitch = false;
|
|
122
|
+
|
|
123
|
+
function registerTodoToolWithCurrentPrompt(): void {
|
|
124
|
+
const thinkingPrompt = todoThinkingEnabled ? buildThinkingPromptParts(currentModel) : {};
|
|
125
|
+
registerTodoTool(pi, {
|
|
126
|
+
...thinkingPrompt,
|
|
127
|
+
afterCommit: async (state, ctx, info) => {
|
|
128
|
+
if (todoThinkingEnabled) applyTodoThinkingAfterCommit(state, info);
|
|
129
|
+
try {
|
|
130
|
+
const sync = syncPersistedPlan(ctx.cwd, state);
|
|
131
|
+
if (sync?.completed) console.log(`rpiv-todo: completed persisted plan and removed ${sync.path}`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn(`rpiv-todo: failed to sync persisted plan — ${(err as Error).message}`);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function applyTodoThinkingAfterCommit(state: ReturnType<typeof getState>, info: { action: string; params: TaskMutationParams }): void {
|
|
140
|
+
const mutations = getTodoThinkingMutations(info.action, info.params);
|
|
141
|
+
for (const mutation of mutations) {
|
|
142
|
+
if (mutation.id === undefined || mutation.status === "in_progress") continue;
|
|
143
|
+
restoreTaskThinking(mutation.id);
|
|
144
|
+
}
|
|
145
|
+
restoreInactiveTodoThinking(state);
|
|
146
|
+
for (const mutation of mutations) {
|
|
147
|
+
if (mutation.id === undefined) continue;
|
|
148
|
+
const task = state.tasks.find((item) => item.id === mutation.id);
|
|
149
|
+
if (!task || task.status !== "in_progress" || !task.thinking) continue;
|
|
150
|
+
if (mutation.status !== "in_progress" && mutation.thinking === undefined) continue;
|
|
151
|
+
switchToTaskThinking(task.id, task.thinking);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getTodoThinkingMutations(action: string, params: TaskMutationParams): TaskMutationParams[] {
|
|
156
|
+
if (action === "update") return [params];
|
|
157
|
+
if (action === "batch_update") return params.items ?? [];
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getCurrentThinkingLevel(): TodoThinkingLevel | undefined {
|
|
162
|
+
const level = (pi as { getThinkingLevel?: () => unknown }).getThinkingLevel?.();
|
|
163
|
+
return isTodoThinkingLevel(level) ? level : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function switchToTaskThinking(taskId: number, level: TodoThinkingLevel): void {
|
|
167
|
+
if (!getAvailableTodoThinkingLevels(currentModel).includes(level)) return;
|
|
168
|
+
const current = getCurrentThinkingLevel();
|
|
169
|
+
if (!current) return;
|
|
170
|
+
if (!rememberedThinkingByTaskId.has(taskId)) rememberedThinkingByTaskId.set(taskId, current);
|
|
171
|
+
if (current !== level) setTodoThinkingLevel(level);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function restoreTaskThinking(taskId: number): void {
|
|
175
|
+
const previous = rememberedThinkingByTaskId.get(taskId);
|
|
176
|
+
if (!previous) return;
|
|
177
|
+
rememberedThinkingByTaskId.delete(taskId);
|
|
178
|
+
if (getCurrentThinkingLevel() !== previous) setTodoThinkingLevel(previous);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function restoreInactiveTodoThinking(state: ReturnType<typeof getState>): void {
|
|
182
|
+
for (const taskId of [...rememberedThinkingByTaskId.keys()]) {
|
|
183
|
+
const task = state.tasks.find((item) => item.id === taskId);
|
|
184
|
+
if (task?.status === "in_progress") continue;
|
|
185
|
+
restoreTaskThinking(taskId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function setTodoThinkingLevel(level: TodoThinkingLevel): void {
|
|
190
|
+
const setter = (pi as { setThinkingLevel?: (level: TodoThinkingLevel) => void }).setThinkingLevel;
|
|
191
|
+
if (!setter) return;
|
|
192
|
+
suppressNextNudgeForThinkingSwitch = true;
|
|
193
|
+
setter.call(pi, level);
|
|
194
|
+
}
|
|
90
195
|
|
|
91
196
|
function clearNudgeTimer(): void {
|
|
92
197
|
if (!nudgeTimer) return;
|
|
@@ -127,19 +232,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
127
232
|
}, delayMs);
|
|
128
233
|
}
|
|
129
234
|
|
|
130
|
-
|
|
131
|
-
afterCommit: (state, ctx) => {
|
|
132
|
-
try {
|
|
133
|
-
const sync = syncPersistedPlan(ctx.cwd, state);
|
|
134
|
-
if (sync?.completed) console.log(`rpiv-todo: completed persisted plan and removed ${sync.path}`);
|
|
135
|
-
} catch (err) {
|
|
136
|
-
console.warn(`rpiv-todo: failed to sync persisted plan — ${(err as Error).message}`);
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
});
|
|
235
|
+
registerTodoToolWithCurrentPrompt();
|
|
140
236
|
registerTodosCommand(pi);
|
|
141
237
|
|
|
142
238
|
pi.on("session_start", async (_event, ctx) => {
|
|
239
|
+
currentModel = ctx.model;
|
|
240
|
+
registerTodoToolWithCurrentPrompt();
|
|
143
241
|
const persisted = loadPersistedPlan(ctx.cwd);
|
|
144
242
|
const loaded = autoClearCompletedTodos(persisted?.state ?? replayFromBranch(ctx));
|
|
145
243
|
replaceState(loaded.state);
|
|
@@ -171,6 +269,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
171
269
|
clearNudgeTimer();
|
|
172
270
|
});
|
|
173
271
|
|
|
272
|
+
pi.on("model_select", async (event) => {
|
|
273
|
+
currentModel = event.model;
|
|
274
|
+
if (todoThinkingEnabled) registerTodoToolWithCurrentPrompt();
|
|
275
|
+
});
|
|
276
|
+
|
|
174
277
|
// Reads getTodos() at render time; do NOT call replayFromBranch here
|
|
175
278
|
// (branch is stale — message_end runs after tool_execution_end).
|
|
176
279
|
pi.on("tool_execution_start", async (event) => {
|
|
@@ -188,6 +291,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
291
|
});
|
|
189
292
|
|
|
190
293
|
pi.on("agent_end", async (_event, ctx) => {
|
|
294
|
+
if (suppressNextNudgeForThinkingSwitch) {
|
|
295
|
+
suppressNextNudgeForThinkingSwitch = false;
|
|
296
|
+
clearNudgeTimer();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
191
300
|
if (pendingAskUserToolCallIds.size > 0) {
|
|
192
301
|
clearNudgeTimer();
|
|
193
302
|
return;
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import type { Task, TaskAction, TaskMutationParams,
|
|
1
|
+
import type { Task, TaskAction, TaskMutationParams, TaskStatus, TodoThinkingLevel } from "../tool/types.js";
|
|
2
2
|
import { isTransitionValid } from "./invariants.js";
|
|
3
3
|
import type { TaskState } from "./state.js";
|
|
4
4
|
import { detectCycle } from "./task-graph.js";
|
|
5
5
|
|
|
6
6
|
export type Op =
|
|
7
|
-
| { kind: "create"; taskId: number }
|
|
7
|
+
| { kind: "create"; taskId: number; replacedCount?: number }
|
|
8
8
|
| { kind: "update"; id: number; fromStatus: TaskStatus; toStatus: TaskStatus }
|
|
9
|
-
| { kind: "batch_create"; ids: number[] }
|
|
9
|
+
| { kind: "batch_create"; ids: number[]; replacedCount?: number }
|
|
10
10
|
| { kind: "batch_update"; ids: number[] }
|
|
11
11
|
| { kind: "delete"; id: number; subject: string }
|
|
12
|
-
| { kind: "list"; statusFilter?: TaskStatus;
|
|
12
|
+
| { kind: "list"; statusFilter?: TaskStatus; blockedOnly: boolean; includeDeleted: boolean }
|
|
13
13
|
| { kind: "get"; task: Task }
|
|
14
14
|
| { kind: "clear"; count: number }
|
|
15
|
-
| { kind: "export"; format: "json" | "markdown"; statusFilter?: TaskStatus;
|
|
15
|
+
| { kind: "export"; format: "json" | "markdown"; statusFilter?: TaskStatus; blockedOnly: boolean; includeDeleted: boolean }
|
|
16
16
|
| { kind: "import"; count: number; replaced: boolean }
|
|
17
17
|
| { kind: "error"; message: string };
|
|
18
18
|
|
|
@@ -29,9 +29,8 @@ function uniqueNumbers(values: number[] | undefined): number[] {
|
|
|
29
29
|
return [...new Set((values ?? []).filter((value) => Number.isFinite(value)))];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
return normalized.length ? normalized : undefined;
|
|
32
|
+
function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel {
|
|
33
|
+
return value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
function findTask(state: TaskState, id: number): Task | undefined {
|
|
@@ -87,12 +86,10 @@ function coerceTask(value: unknown, fallbackId: number): Task | undefined {
|
|
|
87
86
|
const task: Task = { id: typeof v.id === "number" && Number.isFinite(v.id) ? v.id : fallbackId, subject, status };
|
|
88
87
|
if (typeof v.description === "string" && v.description) task.description = v.description;
|
|
89
88
|
if (typeof v.activeForm === "string" && v.activeForm) task.activeForm = v.activeForm;
|
|
90
|
-
if (v.
|
|
89
|
+
if (isTodoThinkingLevel(v.thinking)) task.thinking = v.thinking;
|
|
91
90
|
if (typeof v.parentId === "number" && Number.isFinite(v.parentId)) task.parentId = v.parentId;
|
|
92
91
|
const blockedBy = Array.isArray(v.blockedBy) ? uniqueNumbers(v.blockedBy as number[]) : undefined;
|
|
93
92
|
if (blockedBy?.length) task.blockedBy = blockedBy;
|
|
94
|
-
const tags = Array.isArray(v.tags) ? normalizeTags(v.tags.filter((tag): tag is string => typeof tag === "string")) : undefined;
|
|
95
|
-
if (tags) task.tags = tags;
|
|
96
93
|
if (typeof v.owner === "string" && v.owner) task.owner = v.owner;
|
|
97
94
|
if (v.metadata && typeof v.metadata === "object" && !Array.isArray(v.metadata)) task.metadata = { ...(v.metadata as Record<string, unknown>) };
|
|
98
95
|
return task;
|
|
@@ -114,12 +111,11 @@ function parseMarkdownImport(content: string): Task[] {
|
|
|
114
111
|
const tasks: Task[] = [];
|
|
115
112
|
const stack: Array<{ indent: number; id: number }> = [];
|
|
116
113
|
for (const line of content.split(/\r?\n/)) {
|
|
117
|
-
const match = /^(\s*)- \[([ xX])\](?: #(\d+))?
|
|
114
|
+
const match = /^(\s*)- \[([ xX])\](?: #(\d+))? (.+?)$/.exec(line);
|
|
118
115
|
if (!match) continue;
|
|
119
116
|
const indent = match[1].length;
|
|
120
117
|
const id = match[3] ? Number(match[3]) : tasks.length + 1;
|
|
121
|
-
const
|
|
122
|
-
const subjectParts = match[5].split(/\s+⛓\s+/);
|
|
118
|
+
const subjectParts = match[4].split(/\s+⛓\s+/);
|
|
123
119
|
const blockedBy = subjectParts[1] ? uniqueNumbers(subjectParts[1].split(/\s*,\s*/).map((ref) => Number(ref.replace(/^#/, "")))) : [];
|
|
124
120
|
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
125
121
|
const rawSubject = subjectParts[0];
|
|
@@ -132,10 +128,8 @@ function parseMarkdownImport(content: string): Task[] {
|
|
|
132
128
|
subject: rawSubject.replace(/\s+\{deferred\}$/, "").trim(),
|
|
133
129
|
status,
|
|
134
130
|
};
|
|
135
|
-
if (match[4]) task.priority = match[4] as TaskPriority;
|
|
136
131
|
if (stack.length) task.parentId = stack[stack.length - 1].id;
|
|
137
132
|
if (blockedBy.length) task.blockedBy = blockedBy;
|
|
138
|
-
if (tags) task.tags = tags;
|
|
139
133
|
tasks.push(task);
|
|
140
134
|
stack.push({ indent, id });
|
|
141
135
|
}
|
|
@@ -147,7 +141,6 @@ function remapImportedTasks(imported: readonly Task[], state: TaskState, replace
|
|
|
147
141
|
return imported.map((task) => ({
|
|
148
142
|
...task,
|
|
149
143
|
blockedBy: task.blockedBy ? [...task.blockedBy] : undefined,
|
|
150
|
-
tags: task.tags ? [...task.tags] : undefined,
|
|
151
144
|
}));
|
|
152
145
|
}
|
|
153
146
|
const idMap = new Map<number, number>();
|
|
@@ -157,7 +150,6 @@ function remapImportedTasks(imported: readonly Task[], state: TaskState, replace
|
|
|
157
150
|
id: idMap.get(task.id) ?? task.id,
|
|
158
151
|
parentId: task.parentId !== undefined ? (idMap.get(task.parentId) ?? task.parentId) : undefined,
|
|
159
152
|
blockedBy: task.blockedBy?.map((id) => idMap.get(id) ?? id),
|
|
160
|
-
tags: task.tags ? [...task.tags] : undefined,
|
|
161
153
|
}));
|
|
162
154
|
}
|
|
163
155
|
|
|
@@ -165,26 +157,29 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
165
157
|
switch (action) {
|
|
166
158
|
case "create": {
|
|
167
159
|
if (!params.subject?.trim()) return errorResult(state, "subject required for create");
|
|
160
|
+
const replacedCount = params.replace === true ? state.tasks.length : 0;
|
|
161
|
+
const baseState = params.replace === true ? { tasks: [], nextId: 1 } : state;
|
|
168
162
|
if (params.parentId !== undefined && params.parentId !== null) {
|
|
169
|
-
const err = validateLiveReference(
|
|
163
|
+
const err = validateLiveReference(baseState, "parentId", params.parentId);
|
|
170
164
|
if (err) return errorResult(state, err);
|
|
171
165
|
}
|
|
172
166
|
for (const dep of uniqueNumbers(params.blockedBy)) {
|
|
173
|
-
const err = validateLiveReference(
|
|
167
|
+
const err = validateLiveReference(baseState, "blockedBy", dep);
|
|
174
168
|
if (err) return errorResult(state, err);
|
|
175
169
|
}
|
|
176
|
-
const newTask: Task = { id:
|
|
170
|
+
const newTask: Task = { id: baseState.nextId, subject: params.subject.trim(), status: "pending" };
|
|
177
171
|
if (params.description) newTask.description = params.description;
|
|
178
172
|
if (params.activeForm) newTask.activeForm = params.activeForm;
|
|
179
|
-
if (params.
|
|
173
|
+
if (params.thinking) newTask.thinking = params.thinking;
|
|
180
174
|
if (params.parentId !== undefined && params.parentId !== null) newTask.parentId = params.parentId;
|
|
181
175
|
const blockedBy = uniqueNumbers(params.blockedBy);
|
|
182
176
|
if (blockedBy.length) newTask.blockedBy = blockedBy;
|
|
183
|
-
const tags = normalizeTags(params.tags);
|
|
184
|
-
if (tags) newTask.tags = tags;
|
|
185
177
|
if (params.owner) newTask.owner = params.owner;
|
|
186
178
|
if (params.metadata) newTask.metadata = { ...params.metadata };
|
|
187
|
-
return {
|
|
179
|
+
return {
|
|
180
|
+
state: { tasks: [...baseState.tasks, newTask], nextId: baseState.nextId + 1 },
|
|
181
|
+
op: { kind: "create", taskId: newTask.id, ...(replacedCount > 0 ? { replacedCount } : {}) },
|
|
182
|
+
};
|
|
188
183
|
}
|
|
189
184
|
|
|
190
185
|
case "update": {
|
|
@@ -194,8 +189,8 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
194
189
|
const current = state.tasks[idx];
|
|
195
190
|
const hasMutation =
|
|
196
191
|
params.subject !== undefined || params.description !== undefined || params.activeForm !== undefined || params.status !== undefined ||
|
|
197
|
-
params.
|
|
198
|
-
params.metadata !== undefined ||
|
|
192
|
+
params.thinking !== undefined || params.parentId !== undefined || params.clearParent === true || params.owner !== undefined ||
|
|
193
|
+
params.metadata !== undefined ||
|
|
199
194
|
(params.addBlockedBy?.length ?? 0) > 0 || (params.removeBlockedBy?.length ?? 0) > 0;
|
|
200
195
|
if (!hasMutation) return errorResult(state, "update requires at least one mutable field");
|
|
201
196
|
|
|
@@ -230,13 +225,6 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
230
225
|
if (detectCycle(state.tasks, current.id, newBlockedBy)) return errorResult(state, "addBlockedBy would create a cycle in the blockedBy graph");
|
|
231
226
|
}
|
|
232
227
|
|
|
233
|
-
let newTags = params.tags !== undefined ? normalizeTags(params.tags) : current.tags ? [...current.tags] : undefined;
|
|
234
|
-
if (params.addTags?.length) newTags = normalizeTags([...(newTags ?? []), ...params.addTags]);
|
|
235
|
-
if (params.removeTags?.length && newTags) {
|
|
236
|
-
const remove = new Set(params.removeTags.map((tag) => tag.trim()).filter(Boolean));
|
|
237
|
-
newTags = normalizeTags(newTags.filter((tag) => !remove.has(tag)));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
228
|
let newMetadata = current.metadata;
|
|
241
229
|
if (params.metadata !== undefined) {
|
|
242
230
|
const merged: Record<string, unknown> = { ...(current.metadata ?? {}) };
|
|
@@ -248,14 +236,12 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
248
236
|
if (params.subject !== undefined) updated.subject = params.subject;
|
|
249
237
|
if (params.description !== undefined) updated.description = params.description;
|
|
250
238
|
if (params.activeForm !== undefined) updated.activeForm = params.activeForm;
|
|
251
|
-
if (params.
|
|
239
|
+
if (params.thinking !== undefined) updated.thinking = params.thinking;
|
|
252
240
|
if (params.owner !== undefined) updated.owner = params.owner;
|
|
253
241
|
if (newParentId === undefined) delete updated.parentId;
|
|
254
242
|
else updated.parentId = newParentId;
|
|
255
243
|
if (newBlockedBy.length) updated.blockedBy = newBlockedBy;
|
|
256
244
|
else delete updated.blockedBy;
|
|
257
|
-
if (newTags) updated.tags = newTags;
|
|
258
|
-
else delete updated.tags;
|
|
259
245
|
if (newMetadata === undefined) delete updated.metadata;
|
|
260
246
|
else updated.metadata = newMetadata;
|
|
261
247
|
|
|
@@ -266,7 +252,8 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
266
252
|
|
|
267
253
|
case "batch_create": {
|
|
268
254
|
if (!params.items?.length) return errorResult(state, "items required for batch_create");
|
|
269
|
-
|
|
255
|
+
const replacedCount = params.replace === true ? state.tasks.length : 0;
|
|
256
|
+
let working = params.replace === true ? { tasks: [], nextId: 1 } : state;
|
|
270
257
|
const ids: number[] = [];
|
|
271
258
|
for (let i = 0; i < params.items.length; i++) {
|
|
272
259
|
const result = applyTaskMutation(working, "create", { ...params.items[i], action: "create" });
|
|
@@ -274,7 +261,7 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
274
261
|
if (result.op.kind === "create") ids.push(result.op.taskId);
|
|
275
262
|
working = result.state;
|
|
276
263
|
}
|
|
277
|
-
return { state: working, op: { kind: "batch_create", ids } };
|
|
264
|
+
return { state: working, op: { kind: "batch_create", ids, ...(replacedCount > 0 ? { replacedCount } : {}) } };
|
|
278
265
|
}
|
|
279
266
|
|
|
280
267
|
case "batch_update": {
|
|
@@ -298,8 +285,6 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
298
285
|
includeDeleted: params.includeDeleted === true,
|
|
299
286
|
blockedOnly: params.blockedOnly === true,
|
|
300
287
|
...(params.status ? { statusFilter: params.status } : {}),
|
|
301
|
-
...(params.priority ? { priorityFilter: params.priority } : {}),
|
|
302
|
-
...(params.tag ? { tagFilter: params.tag } : {}),
|
|
303
288
|
},
|
|
304
289
|
};
|
|
305
290
|
|
|
@@ -337,8 +322,6 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
337
322
|
includeDeleted: params.includeDeleted === true,
|
|
338
323
|
blockedOnly: params.blockedOnly === true,
|
|
339
324
|
...(params.status ? { statusFilter: params.status } : {}),
|
|
340
|
-
...(params.priority ? { priorityFilter: params.priority } : {}),
|
|
341
|
-
...(params.tag ? { tagFilter: params.tag } : {}),
|
|
342
325
|
},
|
|
343
326
|
};
|
|
344
327
|
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
type Task,
|
|
35
35
|
type TaskAction,
|
|
36
36
|
type TaskMutationParams,
|
|
37
|
-
type TaskPriority,
|
|
38
37
|
type TaskStatus,
|
|
39
38
|
TOOL_LABEL,
|
|
40
39
|
TOOL_NAME,
|
|
@@ -63,8 +62,6 @@ const TODOS_ARGUMENT_COMPLETIONS: CommandCompletion[] = [
|
|
|
63
62
|
{ value: "--ready", label: "--ready", description: "Show pending tasks whose blockers are completed" },
|
|
64
63
|
{ value: "--blocked", label: "--blocked", description: "Show tasks with blockers" },
|
|
65
64
|
{ value: "--tree", label: "--tree", description: "Show parent/subtask tree" },
|
|
66
|
-
{ value: "--tag ", label: "--tag <tag>", description: "Filter by tag" },
|
|
67
|
-
{ value: "--priority ", label: "--priority <level>", description: "Filter by priority" },
|
|
68
65
|
{ value: "--status ", label: "--status <status>", description: "Filter by status" },
|
|
69
66
|
{ value: "--export markdown", label: "--export markdown", description: "Export visible todos as Markdown" },
|
|
70
67
|
{ value: "--export json", label: "--export json", description: "Export visible todos as JSON" },
|
|
@@ -78,7 +75,12 @@ const PERSIST_ARGUMENT_COMPLETIONS: CommandCompletion[] = [
|
|
|
78
75
|
];
|
|
79
76
|
|
|
80
77
|
interface TodoToolHooks {
|
|
81
|
-
afterCommit?: (state: ReturnType<typeof getState>, ctx: ExtensionContext) => void | Promise<void>;
|
|
78
|
+
afterCommit?: (state: ReturnType<typeof getState>, ctx: ExtensionContext, info: { action: TaskAction; params: TaskMutationParams }) => void | Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface TodoToolRegistrationOptions extends TodoToolHooks {
|
|
82
|
+
promptSnippet?: string;
|
|
83
|
+
promptGuidelines?: string[];
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
type TodoStateEventContext = { sessionManager?: { getSessionFile?: () => unknown } };
|
|
@@ -86,8 +88,6 @@ type TodoStateEventEmitter = { events?: { emit?: (channel: string, data: unknown
|
|
|
86
88
|
|
|
87
89
|
interface TodosCommandOptions {
|
|
88
90
|
status?: TaskStatus;
|
|
89
|
-
priority?: TaskPriority;
|
|
90
|
-
tag?: string;
|
|
91
91
|
blockedOnly: boolean;
|
|
92
92
|
readyOnly: boolean;
|
|
93
93
|
activeOnly: boolean;
|
|
@@ -125,12 +125,6 @@ function parseTodosArgs(args: unknown): TodosCommandOptions {
|
|
|
125
125
|
case "--include-deleted":
|
|
126
126
|
options.includeDeleted = true;
|
|
127
127
|
break;
|
|
128
|
-
case "--tag":
|
|
129
|
-
if (next) options.tag = tokens[++i].replace(/^#/, "");
|
|
130
|
-
break;
|
|
131
|
-
case "--priority":
|
|
132
|
-
if (next === "low" || next === "medium" || next === "high" || next === "urgent") options.priority = tokens[++i] as TaskPriority;
|
|
133
|
-
break;
|
|
134
128
|
case "--status":
|
|
135
129
|
if (next === "pending" || next === "in_progress" || next === "deferred" || next === "completed" || next === "deleted") options.status = tokens[++i] as TaskStatus;
|
|
136
130
|
break;
|
|
@@ -290,9 +284,6 @@ function filterCommandTasks(tasks: readonly Task[], options: TodosCommandOptions
|
|
|
290
284
|
view = view.filter((task) => task.status === "pending" && !isTaskBlocked(task, byId));
|
|
291
285
|
}
|
|
292
286
|
if (options.status) view = view.filter((task) => task.status === options.status);
|
|
293
|
-
if (options.priority) view = view.filter((task) => task.priority === options.priority);
|
|
294
|
-
const tag = options.tag;
|
|
295
|
-
if (tag) view = view.filter((task) => task.tags?.includes(tag));
|
|
296
287
|
if (options.blockedOnly) view = view.filter((task) => (task.blockedBy?.length ?? 0) > 0);
|
|
297
288
|
return view;
|
|
298
289
|
}
|
|
@@ -343,7 +334,7 @@ export { isTransitionValid } from "./state/invariants.js";
|
|
|
343
334
|
export { applyTaskMutation } from "./state/state-reducer.js";
|
|
344
335
|
export { __resetState, getNextId, getTodos } from "./state/store.js";
|
|
345
336
|
export { deriveBlocks, detectCycle } from "./state/task-graph.js";
|
|
346
|
-
export type { Task, TaskAction, TaskDetails,
|
|
337
|
+
export type { Task, TaskAction, TaskDetails, TaskStatus } from "./tool/types.js";
|
|
347
338
|
export { TOOL_NAME } from "./tool/types.js";
|
|
348
339
|
|
|
349
340
|
/**
|
|
@@ -362,13 +353,13 @@ export function reconstructTodoState(ctx: Parameters<typeof replayFromBranch>[0]
|
|
|
362
353
|
export const DEFAULT_PROMPT_SNIPPET = TODO_TOOL_DESCRIPTION.promptSnippet ?? "";
|
|
363
354
|
export const DEFAULT_PROMPT_GUIDELINES: string[] = TODO_TOOL_DESCRIPTION.promptGuidelines ?? [];
|
|
364
355
|
|
|
365
|
-
export function registerTodoTool(pi: ExtensionAPI, hooks:
|
|
356
|
+
export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolRegistrationOptions = {}): void {
|
|
366
357
|
pi.registerTool({
|
|
367
358
|
...TODO_TOOL_DESCRIPTION,
|
|
368
359
|
name: TOOL_NAME,
|
|
369
360
|
label: TOOL_LABEL,
|
|
370
|
-
promptSnippet: DEFAULT_PROMPT_SNIPPET,
|
|
371
|
-
promptGuidelines: DEFAULT_PROMPT_GUIDELINES,
|
|
361
|
+
promptSnippet: hooks.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
|
|
362
|
+
promptGuidelines: hooks.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
|
|
372
363
|
parameters: TodoParamsSchema,
|
|
373
364
|
|
|
374
365
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
@@ -379,7 +370,7 @@ export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolHooks = {}): v
|
|
|
379
370
|
const autoClear = autoClearCompletedTodos(result.state);
|
|
380
371
|
commitState(autoClear.state);
|
|
381
372
|
publishTodoState(pi as TodoStateEventEmitter, _ctx, params.action, params as Record<string, unknown>);
|
|
382
|
-
await hooks.afterCommit?.(autoClear.state, _ctx as ExtensionContext);
|
|
373
|
+
await hooks.afterCommit?.(autoClear.state, _ctx as ExtensionContext, { action: params.action, params: params as TaskMutationParams });
|
|
383
374
|
const toolResult = buildToolResult(params.action, params as TaskMutationParams, autoClear.state, result.op);
|
|
384
375
|
if (!autoClear.cleared) return toolResult;
|
|
385
376
|
return {
|
|
@@ -397,7 +388,7 @@ export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolHooks = {}): v
|
|
|
397
388
|
|
|
398
389
|
export function registerTodosCommand(pi: ExtensionAPI): void {
|
|
399
390
|
pi.registerCommand(COMMAND_NAME, {
|
|
400
|
-
|
|
391
|
+
description: "Show todos on the current branch. Flags: --active, --ready, --blocked, --tree, --status <status>, --export [json|markdown]. Commands: persist on|off|clear|status, scope <id...>",
|
|
401
392
|
getArgumentCompletions: (prefix) => completeCommandArguments(String(prefix ?? ""), TODOS_ARGUMENT_COMPLETIONS),
|
|
402
393
|
handler: async (args, ctx) => {
|
|
403
394
|
if (handlePersistCommand(args, ctx)) return;
|
|
@@ -416,8 +407,6 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
|
|
|
416
407
|
includeDeleted: options.includeDeleted,
|
|
417
408
|
blockedOnly: options.blockedOnly,
|
|
418
409
|
...(options.status ? { statusFilter: options.status } : {}),
|
|
419
|
-
...(options.priority ? { priorityFilter: options.priority } : {}),
|
|
420
|
-
...(options.tag ? { tagFilter: options.tag } : {}),
|
|
421
410
|
};
|
|
422
411
|
ctx.ui.notify(formatContent(op, exportState), "info");
|
|
423
412
|
return;
|
|
@@ -10,10 +10,9 @@ import type { Task, TaskAction, TaskDetails, TaskMutationParams } from "./types.
|
|
|
10
10
|
function formatListLine(t: Task): string {
|
|
11
11
|
const block = t.blockedBy?.length ? ` ⛓ ${t.blockedBy.map((id) => `#${id}`).join(",")}` : "";
|
|
12
12
|
const parent = t.parentId !== undefined ? ` ↳ #${t.parentId}` : "";
|
|
13
|
-
const
|
|
14
|
-
const tags = t.tags?.length ? ` ${t.tags.map((tag) => `#${tag}`).join(" ")}` : "";
|
|
13
|
+
const thinking = t.thinking ? ` {thinking:${t.thinking}}` : "";
|
|
15
14
|
const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
|
|
16
|
-
return `[${t.status}] #${t.id} ${t.subject}${
|
|
15
|
+
return `[${t.status}] #${t.id} ${t.subject}${thinking}${form}${parent}${block}`;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
/**
|
|
@@ -25,7 +24,7 @@ function formatGetLines(task: Task, state: TaskState): string {
|
|
|
25
24
|
const lines = [`#${task.id} [${task.status}] ${task.subject}`];
|
|
26
25
|
if (task.description) lines.push(` description: ${task.description}`);
|
|
27
26
|
if (task.activeForm) lines.push(` activeForm: ${task.activeForm}`);
|
|
28
|
-
if (task.
|
|
27
|
+
if (task.thinking) lines.push(` thinking: ${task.thinking}`);
|
|
29
28
|
if (task.parentId !== undefined) lines.push(` parentId: #${task.parentId}`);
|
|
30
29
|
if (task.blockedBy?.length) {
|
|
31
30
|
lines.push(` blockedBy: ${task.blockedBy.map((id) => `#${id}`).join(", ")}`);
|
|
@@ -33,7 +32,6 @@ function formatGetLines(task: Task, state: TaskState): string {
|
|
|
33
32
|
if (blocks.length) {
|
|
34
33
|
lines.push(` blocks: ${blocks.map((id) => `#${id}`).join(", ")}`);
|
|
35
34
|
}
|
|
36
|
-
if (task.tags?.length) lines.push(` tags: ${task.tags.map((tag) => `#${tag}`).join(" ")}`);
|
|
37
35
|
if (task.owner) lines.push(` owner: ${task.owner}`);
|
|
38
36
|
return lines.join("\n");
|
|
39
37
|
}
|
|
@@ -42,13 +40,15 @@ function filterTasks(op: Extract<Op, { kind: "list" | "export" }>, state: TaskSt
|
|
|
42
40
|
let view = state.tasks;
|
|
43
41
|
if (!op.includeDeleted) view = view.filter((t) => t.status !== "deleted");
|
|
44
42
|
if (op.statusFilter) view = view.filter((t) => t.status === op.statusFilter);
|
|
45
|
-
if (op.priorityFilter) view = view.filter((t) => t.priority === op.priorityFilter);
|
|
46
|
-
const tagFilter = op.tagFilter;
|
|
47
|
-
if (tagFilter) view = view.filter((t) => t.tags?.includes(tagFilter));
|
|
48
43
|
if (op.blockedOnly) view = view.filter((t) => (t.blockedBy?.length ?? 0) > 0);
|
|
49
44
|
return view;
|
|
50
45
|
}
|
|
51
46
|
|
|
47
|
+
function formatReplacePrefix(replacedCount: number | undefined): string {
|
|
48
|
+
if (!replacedCount) return "";
|
|
49
|
+
return `Replaced ${replacedCount} existing todo item${replacedCount === 1 ? "" : "s"}; `;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
52
|
function formatMarkdownExport(tasks: readonly Task[]): string {
|
|
53
53
|
const byParent = new Map<number | undefined, Task[]>();
|
|
54
54
|
for (const task of tasks) {
|
|
@@ -62,11 +62,9 @@ function formatMarkdownExport(tasks: readonly Task[]): string {
|
|
|
62
62
|
if (seen.has(task.id)) return;
|
|
63
63
|
seen.add(task.id);
|
|
64
64
|
const checked = task.status === "completed" ? "x" : " ";
|
|
65
|
-
const priority = task.priority ? ` (${task.priority})` : "";
|
|
66
65
|
const status = task.status === "deferred" ? " {deferred}" : "";
|
|
67
|
-
const tags = task.tags?.length ? ` [${task.tags.map((tag) => `#${tag}`).join(" ")}]` : "";
|
|
68
66
|
const blocked = task.blockedBy?.length ? ` ⛓ ${task.blockedBy.map((id) => `#${id}`).join(",")}` : "";
|
|
69
|
-
lines.push(`${" ".repeat(depth)}- [${checked}] #${task.id}
|
|
67
|
+
lines.push(`${" ".repeat(depth)}- [${checked}] #${task.id} ${task.subject}${status}${blocked}`);
|
|
70
68
|
for (const child of byParent.get(task.id) ?? []) visit(child, depth + 1);
|
|
71
69
|
};
|
|
72
70
|
for (const root of byParent.get(undefined) ?? []) visit(root, 0);
|
|
@@ -85,15 +83,15 @@ export function formatContent(op: Op, state: TaskState): string {
|
|
|
85
83
|
case "create": {
|
|
86
84
|
const t = state.tasks.find((x) => x.id === op.taskId);
|
|
87
85
|
// Defensive — `op.taskId` always resolves on success path.
|
|
88
|
-
if (!t) return
|
|
89
|
-
return
|
|
86
|
+
if (!t) return `${formatReplacePrefix(op.replacedCount)}Created #${op.taskId}`;
|
|
87
|
+
return `${formatReplacePrefix(op.replacedCount)}Created #${t.id}: ${t.subject} (pending)`;
|
|
90
88
|
}
|
|
91
89
|
case "update": {
|
|
92
90
|
const transition = op.fromStatus !== op.toStatus ? ` (${op.fromStatus} → ${op.toStatus})` : "";
|
|
93
91
|
return `Updated #${op.id}${transition}`;
|
|
94
92
|
}
|
|
95
93
|
case "batch_create":
|
|
96
|
-
return
|
|
94
|
+
return `${formatReplacePrefix(op.replacedCount)}Created ${op.ids.length} tasks: ${op.ids.map((id) => `#${id}`).join(", ")}`;
|
|
97
95
|
case "batch_update":
|
|
98
96
|
return `Updated ${op.ids.length} tasks: ${op.ids.map((id) => `#${id}`).join(", ")}`;
|
|
99
97
|
case "delete":
|
|
@@ -144,8 +142,28 @@ export function buildToolResult(
|
|
|
144
142
|
|
|
145
143
|
function appendWorkflowReminder(text: string, op: Op, state: TaskState): string {
|
|
146
144
|
if (op.kind === "error" || op.kind === "export") return text;
|
|
145
|
+
const lines = [text];
|
|
146
|
+
if (op.kind === "create" || op.kind === "batch_create") {
|
|
147
|
+
lines.push(
|
|
148
|
+
"Reminder: if this is a multi-step task, include a final todo item for the user-facing final report before completion. Give that final-report todo an explicit description/acceptance criteria: summarize changed files and behavior, list verification commands/results, mention any remaining manual action, and never replace the user-facing report with a compression/housekeeping note.",
|
|
149
|
+
);
|
|
150
|
+
const createdIds = new Set(op.kind === "create" ? [op.taskId] : op.ids);
|
|
151
|
+
const hasOlderUnfinished = !op.replacedCount && state.tasks.some((task) => {
|
|
152
|
+
if (createdIds.has(task.id)) return false;
|
|
153
|
+
return task.status !== "completed" && task.status !== "deleted";
|
|
154
|
+
});
|
|
155
|
+
if (hasOlderUnfinished) {
|
|
156
|
+
lines.push(
|
|
157
|
+
"Reminder: existing unfinished todos are still present. If this is a new plan that supersedes them, use batch_create with replace:true or explicitly update/defer/delete obsolete tasks.",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
147
161
|
const hasPending = state.tasks.some((task) => task.status === "pending");
|
|
148
162
|
const hasInProgress = state.tasks.some((task) => task.status === "in_progress");
|
|
149
|
-
if (
|
|
150
|
-
|
|
163
|
+
if (hasPending && !hasInProgress) {
|
|
164
|
+
lines.push(
|
|
165
|
+
"Reminder: pending todos exist but none is in_progress. Before starting work, call todo update on exactly one task with status in_progress and activeForm.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n\n");
|
|
151
169
|
}
|