pi-ui-extend 0.1.13 → 0.1.15

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.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/dist/app/app.d.ts +5 -0
  3. package/dist/app/app.js +82 -12
  4. package/dist/app/commands/command-controller.js +1 -0
  5. package/dist/app/commands/command-host.d.ts +3 -0
  6. package/dist/app/commands/command-model-actions.d.ts +2 -0
  7. package/dist/app/commands/command-model-actions.js +40 -4
  8. package/dist/app/commands/command-navigation-actions.js +3 -0
  9. package/dist/app/commands/command-registry.d.ts +1 -0
  10. package/dist/app/commands/command-registry.js +8 -0
  11. package/dist/app/extensions/extension-ui-controller.d.ts +16 -5
  12. package/dist/app/extensions/extension-ui-controller.js +99 -61
  13. package/dist/app/input/input-action-controller.d.ts +1 -0
  14. package/dist/app/input/input-action-controller.js +8 -2
  15. package/dist/app/logger.d.ts +25 -0
  16. package/dist/app/logger.js +90 -0
  17. package/dist/app/model/model-usage-status.js +30 -15
  18. package/dist/app/popup/menu-items-controller.d.ts +2 -0
  19. package/dist/app/popup/menu-items-controller.js +45 -6
  20. package/dist/app/popup/popup-action-controller.d.ts +2 -1
  21. package/dist/app/popup/popup-action-controller.js +7 -4
  22. package/dist/app/popup/popup-menu-controller.d.ts +36 -23
  23. package/dist/app/popup/popup-menu-controller.js +68 -322
  24. package/dist/app/rendering/conversation-entry-renderer.js +3 -3
  25. package/dist/app/rendering/conversation-viewport.d.ts +10 -2
  26. package/dist/app/rendering/conversation-viewport.js +157 -16
  27. package/dist/app/rendering/editor-panels.js +4 -2
  28. package/dist/app/rendering/popup-menu-renderer.d.ts +50 -0
  29. package/dist/app/rendering/popup-menu-renderer.js +307 -0
  30. package/dist/app/rendering/render-controller.js +5 -13
  31. package/dist/app/rendering/status-line-renderer.d.ts +1 -1
  32. package/dist/app/rendering/status-line-renderer.js +27 -24
  33. package/dist/app/rendering/toast-controller.d.ts +11 -3
  34. package/dist/app/rendering/toast-controller.js +53 -12
  35. package/dist/app/runtime.d.ts +2 -1
  36. package/dist/app/runtime.js +20 -10
  37. package/dist/app/screen/mouse-controller.d.ts +2 -2
  38. package/dist/app/screen/mouse-controller.js +27 -48
  39. package/dist/app/screen/screen-styler.d.ts +1 -1
  40. package/dist/app/screen/screen-styler.js +9 -7
  41. package/dist/app/screen/scroll-controller.d.ts +11 -9
  42. package/dist/app/screen/scroll-controller.js +50 -45
  43. package/dist/app/session/lazy-session-manager.d.ts +11 -0
  44. package/dist/app/session/lazy-session-manager.js +539 -0
  45. package/dist/app/session/pix-system-message.d.ts +16 -0
  46. package/dist/app/session/pix-system-message.js +64 -0
  47. package/dist/app/session/session-event-controller.d.ts +11 -0
  48. package/dist/app/session/session-event-controller.js +58 -2
  49. package/dist/app/session/session-history.d.ts +18 -0
  50. package/dist/app/session/session-history.js +72 -3
  51. package/dist/app/session/session-lifecycle-controller.d.ts +6 -2
  52. package/dist/app/session/session-lifecycle-controller.js +7 -2
  53. package/dist/app/session/tabs-controller.d.ts +13 -1
  54. package/dist/app/session/tabs-controller.js +248 -27
  55. package/dist/app/todo/todo-model.d.ts +3 -1
  56. package/dist/app/todo/todo-model.js +14 -2
  57. package/dist/app/types.d.ts +5 -2
  58. package/dist/app/workspace/workspace-actions-controller.d.ts +2 -0
  59. package/dist/app/workspace/workspace-actions-controller.js +12 -0
  60. package/dist/config.d.ts +5 -1
  61. package/dist/config.js +73 -25
  62. package/dist/default-pix-config.js +2 -0
  63. package/dist/schemas/pi-tools-suite-schema.d.ts +1 -0
  64. package/dist/schemas/pi-tools-suite-schema.js +1 -0
  65. package/dist/schemas/pix-schema.d.ts +2 -1
  66. package/dist/schemas/pix-schema.js +5 -4
  67. package/dist/terminal-width.d.ts +2 -0
  68. package/dist/terminal-width.js +64 -3
  69. package/external/pi-tools-suite/README.md +1 -0
  70. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +12 -3
  71. package/external/pi-tools-suite/src/antigravity-auth/commands.ts +2 -4
  72. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +2 -2
  73. package/external/pi-tools-suite/src/antigravity-auth/index.ts +8 -2
  74. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +102 -50
  75. package/external/pi-tools-suite/src/antigravity-auth/status.ts +81 -2
  76. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +29 -8
  77. package/external/pi-tools-suite/src/config.ts +8 -0
  78. package/external/pi-tools-suite/src/dcp/index.ts +16 -1
  79. package/external/pi-tools-suite/src/dcp/state.ts +35 -0
  80. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +3 -0
  81. package/external/pi-tools-suite/src/todo/index.ts +181 -11
  82. package/external/pi-tools-suite/src/todo/state/state-reducer.ts +23 -10
  83. package/external/pi-tools-suite/src/todo/todo.ts +10 -5
  84. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +33 -6
  85. package/external/pi-tools-suite/src/todo/tool/types.ts +9 -1
  86. package/external/pi-tools-suite/src/todo/view/format.ts +2 -1
  87. package/external/pi-tools-suite/src/tool-descriptions.ts +2 -1
  88. package/external/pi-tools-suite/src/usage/index.ts +5 -2
  89. package/external/pi-tools-suite/src/usage/lib/google.ts +6 -13
  90. package/package.json +1 -1
  91. package/schemas/pi-tools-suite.json +4 -0
  92. package/schemas/pix.json +6 -2
@@ -1,16 +1,46 @@
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_RECONCILE_ACTIONS = new Set(["list"]);
17
+ const TODO_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
18
+
19
+ type TodoThinkingLevel = (typeof TODO_THINKING_LEVELS)[number];
20
+ type ModelLike = { reasoning?: boolean; thinkingLevelMap?: Partial<Record<TodoThinkingLevel, unknown | null>> };
21
+
22
+ function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel {
23
+ return TODO_THINKING_LEVELS.includes(value as TodoThinkingLevel);
24
+ }
25
+
26
+ function getAvailableTodoThinkingLevels(model: unknown): TodoThinkingLevel[] {
27
+ const m = model as ModelLike | undefined;
28
+ if (!m?.reasoning) return ["off"];
29
+ const map = m.thinkingLevelMap;
30
+ return TODO_THINKING_LEVELS.filter((level) => level === "off" || map?.[level] !== null);
31
+ }
32
+
33
+ function buildThinkingPromptParts(model: unknown): { promptSnippet?: string; promptGuidelines?: string[] } {
34
+ const levels = getAvailableTodoThinkingLevels(model);
35
+ if (levels.length <= 1) return {};
36
+ return {
37
+ promptSnippet: `${DEFAULT_PROMPT_SNIPPET} Optional per-item thinking: ${levels.join("|")}.`.trim(),
38
+ promptGuidelines: [
39
+ ...DEFAULT_PROMPT_GUIDELINES,
40
+ `If todoThinking is enabled, set task \`thinking\` only when useful; choose from ${levels.join(", ")}. Split todos to change thinking by work type (e.g. final report can use off; retry/hard debugging can use higher thinking).`,
41
+ ],
42
+ };
43
+ }
14
44
 
15
45
  function isAskUserToolName(toolName: string): boolean {
16
46
  return ASK_USER_TOOL_NAMES.has(toolName);
@@ -54,6 +84,39 @@ function getUnfinishedTodoNudge(): { signature: string; message: string } | unde
54
84
  };
55
85
  }
56
86
 
87
+ function getTodoFinalGuardPrompt(): { signature: string; message: string } | undefined {
88
+ const nudge = getUnfinishedTodoNudge();
89
+ if (!nudge) return undefined;
90
+ return {
91
+ signature: nudge.signature,
92
+ message: nudge.message.replace(
93
+ "Todo auto-nudge: unfinished todo items remain after your last response.",
94
+ "Todo final reconciliation required before answering the user.",
95
+ ).replace(
96
+ "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.",
97
+ "Run `todo list` first, reconcile the visible plan, then answer only after completed work is not left pending/in_progress/deferred by accident.",
98
+ ),
99
+ };
100
+ }
101
+
102
+ function isFinalAssistantMessage(message: unknown): boolean {
103
+ if (!message || typeof message !== "object") return false;
104
+ const record = message as Record<string, unknown>;
105
+ if (record.role !== "assistant" || record.stopReason !== "stop") return false;
106
+ const content = record.content;
107
+ if (typeof content === "string") return content.trim().length > 0;
108
+ if (!Array.isArray(content)) return false;
109
+
110
+ let hasText = false;
111
+ for (const block of content) {
112
+ if (!block || typeof block !== "object") continue;
113
+ const part = block as Record<string, unknown>;
114
+ if (part.type === "toolCall" || part.type === "tool-call") return false;
115
+ if (typeof part.text === "string" && part.text.trim().length > 0) hasText = true;
116
+ }
117
+ return hasText;
118
+ }
119
+
57
120
  function getPersistedPlanPrompt(path: string): string | undefined {
58
121
  const unfinished = selectVisibleTasks(getState()).filter((task) => task.status !== "completed");
59
122
  if (unfinished.length === 0) return undefined;
@@ -84,9 +147,95 @@ function emitPersistedPlanPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt
84
147
  }
85
148
 
86
149
  export default function (pi: ExtensionAPI) {
150
+ let currentModel: unknown;
151
+ const todoThinkingEnabled = loadPiToolsSuiteConfig(["todo"]).todoThinking;
152
+ const rememberedThinkingByTaskId = new Map<number, TodoThinkingLevel>();
87
153
  let lastNudgedSignature: string | undefined;
154
+ let lastFinalGuardSignature: string | undefined;
155
+ let todoDirtySinceReconcile = false;
88
156
  let nudgeTimer: ReturnType<typeof setTimeout> | undefined;
89
157
  const pendingAskUserToolCallIds = new Set<string>();
158
+ let suppressNextNudgeForThinkingSwitch = false;
159
+
160
+ function registerTodoToolWithCurrentPrompt(): void {
161
+ const thinkingPrompt = todoThinkingEnabled ? buildThinkingPromptParts(currentModel) : {};
162
+ registerTodoTool(pi, {
163
+ ...thinkingPrompt,
164
+ afterCommit: async (state, ctx, info) => {
165
+ if (todoThinkingEnabled) applyTodoThinkingAfterCommit(state, info);
166
+
167
+ if (TODO_RECONCILE_ACTIONS.has(info.action)) {
168
+ todoDirtySinceReconcile = false;
169
+ lastFinalGuardSignature = undefined;
170
+ } else if (info.action !== "get" && info.action !== "export") {
171
+ todoDirtySinceReconcile = true;
172
+ }
173
+ try {
174
+ const sync = syncPersistedPlan(ctx.cwd, state);
175
+ if (sync?.completed) console.log(`rpiv-todo: completed persisted plan and removed ${sync.path}`);
176
+ } catch (err) {
177
+ console.warn(`rpiv-todo: failed to sync persisted plan — ${(err as Error).message}`);
178
+ }
179
+ },
180
+ });
181
+ }
182
+
183
+ function applyTodoThinkingAfterCommit(state: ReturnType<typeof getState>, info: { action: string; params: TaskMutationParams }): void {
184
+ const mutations = getTodoThinkingMutations(info.action, info.params);
185
+ for (const mutation of mutations) {
186
+ if (mutation.id === undefined || mutation.status === "in_progress") continue;
187
+ restoreTaskThinking(mutation.id);
188
+ }
189
+ restoreInactiveTodoThinking(state);
190
+ for (const mutation of mutations) {
191
+ if (mutation.id === undefined) continue;
192
+ const task = state.tasks.find((item) => item.id === mutation.id);
193
+ if (!task || task.status !== "in_progress" || !task.thinking) continue;
194
+ if (mutation.status !== "in_progress" && mutation.thinking === undefined) continue;
195
+ switchToTaskThinking(task.id, task.thinking);
196
+ }
197
+ }
198
+
199
+ function getTodoThinkingMutations(action: string, params: TaskMutationParams): TaskMutationParams[] {
200
+ if (action === "update") return [params];
201
+ if (action === "batch_update") return params.items ?? [];
202
+ return [];
203
+ }
204
+
205
+ function getCurrentThinkingLevel(): TodoThinkingLevel | undefined {
206
+ const level = (pi as { getThinkingLevel?: () => unknown }).getThinkingLevel?.();
207
+ return isTodoThinkingLevel(level) ? level : undefined;
208
+ }
209
+
210
+ function switchToTaskThinking(taskId: number, level: TodoThinkingLevel): void {
211
+ if (!getAvailableTodoThinkingLevels(currentModel).includes(level)) return;
212
+ const current = getCurrentThinkingLevel();
213
+ if (!current) return;
214
+ if (!rememberedThinkingByTaskId.has(taskId)) rememberedThinkingByTaskId.set(taskId, current);
215
+ if (current !== level) setTodoThinkingLevel(level);
216
+ }
217
+
218
+ function restoreTaskThinking(taskId: number): void {
219
+ const previous = rememberedThinkingByTaskId.get(taskId);
220
+ if (!previous) return;
221
+ rememberedThinkingByTaskId.delete(taskId);
222
+ if (getCurrentThinkingLevel() !== previous) setTodoThinkingLevel(previous);
223
+ }
224
+
225
+ function restoreInactiveTodoThinking(state: ReturnType<typeof getState>): void {
226
+ for (const taskId of [...rememberedThinkingByTaskId.keys()]) {
227
+ const task = state.tasks.find((item) => item.id === taskId);
228
+ if (task?.status === "in_progress") continue;
229
+ restoreTaskThinking(taskId);
230
+ }
231
+ }
232
+
233
+ function setTodoThinkingLevel(level: TodoThinkingLevel): void {
234
+ const setter = (pi as { setThinkingLevel?: (level: TodoThinkingLevel) => void }).setThinkingLevel;
235
+ if (!setter) return;
236
+ suppressNextNudgeForThinkingSwitch = true;
237
+ setter.call(pi, level);
238
+ }
90
239
 
91
240
  function clearNudgeTimer(): void {
92
241
  if (!nudgeTimer) return;
@@ -127,19 +276,12 @@ export default function (pi: ExtensionAPI) {
127
276
  }, delayMs);
128
277
  }
129
278
 
130
- registerTodoTool(pi, {
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
- });
279
+ registerTodoToolWithCurrentPrompt();
140
280
  registerTodosCommand(pi);
141
281
 
142
282
  pi.on("session_start", async (_event, ctx) => {
283
+ currentModel = ctx.model;
284
+ registerTodoToolWithCurrentPrompt();
143
285
  const persisted = loadPersistedPlan(ctx.cwd);
144
286
  const loaded = autoClearCompletedTodos(persisted?.state ?? replayFromBranch(ctx));
145
287
  replaceState(loaded.state);
@@ -171,6 +313,11 @@ export default function (pi: ExtensionAPI) {
171
313
  clearNudgeTimer();
172
314
  });
173
315
 
316
+ pi.on("model_select", async (event) => {
317
+ currentModel = event.model;
318
+ if (todoThinkingEnabled) registerTodoToolWithCurrentPrompt();
319
+ });
320
+
174
321
  // Reads getTodos() at render time; do NOT call replayFromBranch here
175
322
  // (branch is stale — message_end runs after tool_execution_end).
176
323
  pi.on("tool_execution_start", async (event) => {
@@ -185,9 +332,32 @@ export default function (pi: ExtensionAPI) {
185
332
 
186
333
  pi.on("agent_start", async () => {
187
334
  pendingAskUserToolCallIds.clear();
335
+ todoDirtySinceReconcile = false;
336
+ lastFinalGuardSignature = undefined;
337
+ });
338
+
339
+ pi.on("message_end", async (event) => {
340
+ if (!todoDirtySinceReconcile) return;
341
+ if (pendingAskUserToolCallIds.size > 0) return;
342
+ if (!isFinalAssistantMessage(event.message)) return;
343
+
344
+ const guard = getTodoFinalGuardPrompt();
345
+ if (!guard) return;
346
+ if (guard.signature === lastFinalGuardSignature) return;
347
+
348
+ lastFinalGuardSignature = guard.signature;
349
+ lastNudgedSignature = guard.signature;
350
+ clearNudgeTimer();
351
+ pi.sendUserMessage(guard.message, { deliverAs: "followUp" });
188
352
  });
189
353
 
190
354
  pi.on("agent_end", async (_event, ctx) => {
355
+ if (suppressNextNudgeForThinkingSwitch) {
356
+ suppressNextNudgeForThinkingSwitch = false;
357
+ clearNudgeTimer();
358
+ return;
359
+ }
360
+
191
361
  if (pendingAskUserToolCallIds.size > 0) {
192
362
  clearNudgeTimer();
193
363
  return;
@@ -1,12 +1,12 @@
1
- import type { Task, TaskAction, TaskMutationParams, TaskPriority, TaskStatus } from "../tool/types.js";
1
+ import type { Task, TaskAction, TaskMutationParams, TaskPriority, 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
12
  | { kind: "list"; statusFilter?: TaskStatus; priorityFilter?: TaskPriority; tagFilter?: string; blockedOnly: boolean; includeDeleted: boolean }
@@ -34,6 +34,10 @@ function normalizeTags(tags: string[] | undefined): string[] | undefined {
34
34
  return normalized.length ? normalized : undefined;
35
35
  }
36
36
 
37
+ function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel {
38
+ return value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
39
+ }
40
+
37
41
  function findTask(state: TaskState, id: number): Task | undefined {
38
42
  return state.tasks.find((task) => task.id === id);
39
43
  }
@@ -88,6 +92,7 @@ function coerceTask(value: unknown, fallbackId: number): Task | undefined {
88
92
  if (typeof v.description === "string" && v.description) task.description = v.description;
89
93
  if (typeof v.activeForm === "string" && v.activeForm) task.activeForm = v.activeForm;
90
94
  if (v.priority === "low" || v.priority === "medium" || v.priority === "high" || v.priority === "urgent") task.priority = v.priority;
95
+ if (isTodoThinkingLevel(v.thinking)) task.thinking = v.thinking;
91
96
  if (typeof v.parentId === "number" && Number.isFinite(v.parentId)) task.parentId = v.parentId;
92
97
  const blockedBy = Array.isArray(v.blockedBy) ? uniqueNumbers(v.blockedBy as number[]) : undefined;
93
98
  if (blockedBy?.length) task.blockedBy = blockedBy;
@@ -165,18 +170,21 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
165
170
  switch (action) {
166
171
  case "create": {
167
172
  if (!params.subject?.trim()) return errorResult(state, "subject required for create");
173
+ const replacedCount = params.replace === true ? state.tasks.length : 0;
174
+ const baseState = params.replace === true ? { tasks: [], nextId: 1 } : state;
168
175
  if (params.parentId !== undefined && params.parentId !== null) {
169
- const err = validateLiveReference(state, "parentId", params.parentId);
176
+ const err = validateLiveReference(baseState, "parentId", params.parentId);
170
177
  if (err) return errorResult(state, err);
171
178
  }
172
179
  for (const dep of uniqueNumbers(params.blockedBy)) {
173
- const err = validateLiveReference(state, "blockedBy", dep);
180
+ const err = validateLiveReference(baseState, "blockedBy", dep);
174
181
  if (err) return errorResult(state, err);
175
182
  }
176
- const newTask: Task = { id: state.nextId, subject: params.subject.trim(), status: "pending" };
183
+ const newTask: Task = { id: baseState.nextId, subject: params.subject.trim(), status: "pending" };
177
184
  if (params.description) newTask.description = params.description;
178
185
  if (params.activeForm) newTask.activeForm = params.activeForm;
179
186
  if (params.priority) newTask.priority = params.priority;
187
+ if (params.thinking) newTask.thinking = params.thinking;
180
188
  if (params.parentId !== undefined && params.parentId !== null) newTask.parentId = params.parentId;
181
189
  const blockedBy = uniqueNumbers(params.blockedBy);
182
190
  if (blockedBy.length) newTask.blockedBy = blockedBy;
@@ -184,7 +192,10 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
184
192
  if (tags) newTask.tags = tags;
185
193
  if (params.owner) newTask.owner = params.owner;
186
194
  if (params.metadata) newTask.metadata = { ...params.metadata };
187
- return { state: { tasks: [...state.tasks, newTask], nextId: state.nextId + 1 }, op: { kind: "create", taskId: newTask.id } };
195
+ return {
196
+ state: { tasks: [...baseState.tasks, newTask], nextId: baseState.nextId + 1 },
197
+ op: { kind: "create", taskId: newTask.id, ...(replacedCount > 0 ? { replacedCount } : {}) },
198
+ };
188
199
  }
189
200
 
190
201
  case "update": {
@@ -194,7 +205,7 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
194
205
  const current = state.tasks[idx];
195
206
  const hasMutation =
196
207
  params.subject !== undefined || params.description !== undefined || params.activeForm !== undefined || params.status !== undefined ||
197
- params.priority !== undefined || params.parentId !== undefined || params.clearParent === true || params.owner !== undefined ||
208
+ params.priority !== undefined || params.thinking !== undefined || params.parentId !== undefined || params.clearParent === true || params.owner !== undefined ||
198
209
  params.metadata !== undefined || params.tags !== undefined || (params.addTags?.length ?? 0) > 0 || (params.removeTags?.length ?? 0) > 0 ||
199
210
  (params.addBlockedBy?.length ?? 0) > 0 || (params.removeBlockedBy?.length ?? 0) > 0;
200
211
  if (!hasMutation) return errorResult(state, "update requires at least one mutable field");
@@ -249,6 +260,7 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
249
260
  if (params.description !== undefined) updated.description = params.description;
250
261
  if (params.activeForm !== undefined) updated.activeForm = params.activeForm;
251
262
  if (params.priority !== undefined) updated.priority = params.priority;
263
+ if (params.thinking !== undefined) updated.thinking = params.thinking;
252
264
  if (params.owner !== undefined) updated.owner = params.owner;
253
265
  if (newParentId === undefined) delete updated.parentId;
254
266
  else updated.parentId = newParentId;
@@ -266,7 +278,8 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
266
278
 
267
279
  case "batch_create": {
268
280
  if (!params.items?.length) return errorResult(state, "items required for batch_create");
269
- let working = state;
281
+ const replacedCount = params.replace === true ? state.tasks.length : 0;
282
+ let working = params.replace === true ? { tasks: [], nextId: 1 } : state;
270
283
  const ids: number[] = [];
271
284
  for (let i = 0; i < params.items.length; i++) {
272
285
  const result = applyTaskMutation(working, "create", { ...params.items[i], action: "create" });
@@ -274,7 +287,7 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
274
287
  if (result.op.kind === "create") ids.push(result.op.taskId);
275
288
  working = result.state;
276
289
  }
277
- return { state: working, op: { kind: "batch_create", ids } };
290
+ return { state: working, op: { kind: "batch_create", ids, ...(replacedCount > 0 ? { replacedCount } : {}) } };
278
291
  }
279
292
 
280
293
  case "batch_update": {
@@ -78,7 +78,12 @@ const PERSIST_ARGUMENT_COMPLETIONS: CommandCompletion[] = [
78
78
  ];
79
79
 
80
80
  interface TodoToolHooks {
81
- afterCommit?: (state: ReturnType<typeof getState>, ctx: ExtensionContext) => void | Promise<void>;
81
+ afterCommit?: (state: ReturnType<typeof getState>, ctx: ExtensionContext, info: { action: TaskAction; params: TaskMutationParams }) => void | Promise<void>;
82
+ }
83
+
84
+ interface TodoToolRegistrationOptions extends TodoToolHooks {
85
+ promptSnippet?: string;
86
+ promptGuidelines?: string[];
82
87
  }
83
88
 
84
89
  type TodoStateEventContext = { sessionManager?: { getSessionFile?: () => unknown } };
@@ -362,13 +367,13 @@ export function reconstructTodoState(ctx: Parameters<typeof replayFromBranch>[0]
362
367
  export const DEFAULT_PROMPT_SNIPPET = TODO_TOOL_DESCRIPTION.promptSnippet ?? "";
363
368
  export const DEFAULT_PROMPT_GUIDELINES: string[] = TODO_TOOL_DESCRIPTION.promptGuidelines ?? [];
364
369
 
365
- export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolHooks = {}): void {
370
+ export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolRegistrationOptions = {}): void {
366
371
  pi.registerTool({
367
372
  ...TODO_TOOL_DESCRIPTION,
368
373
  name: TOOL_NAME,
369
374
  label: TOOL_LABEL,
370
- promptSnippet: DEFAULT_PROMPT_SNIPPET,
371
- promptGuidelines: DEFAULT_PROMPT_GUIDELINES,
375
+ promptSnippet: hooks.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
376
+ promptGuidelines: hooks.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
372
377
  parameters: TodoParamsSchema,
373
378
 
374
379
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -379,7 +384,7 @@ export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolHooks = {}): v
379
384
  const autoClear = autoClearCompletedTodos(result.state);
380
385
  commitState(autoClear.state);
381
386
  publishTodoState(pi as TodoStateEventEmitter, _ctx, params.action, params as Record<string, unknown>);
382
- await hooks.afterCommit?.(autoClear.state, _ctx as ExtensionContext);
387
+ await hooks.afterCommit?.(autoClear.state, _ctx as ExtensionContext, { action: params.action, params: params as TaskMutationParams });
383
388
  const toolResult = buildToolResult(params.action, params as TaskMutationParams, autoClear.state, result.op);
384
389
  if (!autoClear.cleared) return toolResult;
385
390
  return {
@@ -11,9 +11,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
13
  const priority = t.priority ? ` (${t.priority})` : "";
14
+ const thinking = t.thinking ? ` {thinking:${t.thinking}}` : "";
14
15
  const tags = t.tags?.length ? ` ${t.tags.map((tag) => `#${tag}`).join(" ")}` : "";
15
16
  const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
16
- return `[${t.status}] #${t.id} ${t.subject}${priority}${form}${parent}${block}${tags}`;
17
+ return `[${t.status}] #${t.id} ${t.subject}${priority}${thinking}${form}${parent}${block}${tags}`;
17
18
  }
18
19
 
19
20
  /**
@@ -26,6 +27,7 @@ function formatGetLines(task: Task, state: TaskState): string {
26
27
  if (task.description) lines.push(` description: ${task.description}`);
27
28
  if (task.activeForm) lines.push(` activeForm: ${task.activeForm}`);
28
29
  if (task.priority) lines.push(` priority: ${task.priority}`);
30
+ if (task.thinking) lines.push(` thinking: ${task.thinking}`);
29
31
  if (task.parentId !== undefined) lines.push(` parentId: #${task.parentId}`);
30
32
  if (task.blockedBy?.length) {
31
33
  lines.push(` blockedBy: ${task.blockedBy.map((id) => `#${id}`).join(", ")}`);
@@ -49,6 +51,11 @@ function filterTasks(op: Extract<Op, { kind: "list" | "export" }>, state: TaskSt
49
51
  return view;
50
52
  }
51
53
 
54
+ function formatReplacePrefix(replacedCount: number | undefined): string {
55
+ if (!replacedCount) return "";
56
+ return `Replaced ${replacedCount} existing todo item${replacedCount === 1 ? "" : "s"}; `;
57
+ }
58
+
52
59
  function formatMarkdownExport(tasks: readonly Task[]): string {
53
60
  const byParent = new Map<number | undefined, Task[]>();
54
61
  for (const task of tasks) {
@@ -85,15 +92,15 @@ export function formatContent(op: Op, state: TaskState): string {
85
92
  case "create": {
86
93
  const t = state.tasks.find((x) => x.id === op.taskId);
87
94
  // Defensive — `op.taskId` always resolves on success path.
88
- if (!t) return `Created #${op.taskId}`;
89
- return `Created #${t.id}: ${t.subject} (pending)`;
95
+ if (!t) return `${formatReplacePrefix(op.replacedCount)}Created #${op.taskId}`;
96
+ return `${formatReplacePrefix(op.replacedCount)}Created #${t.id}: ${t.subject} (pending)`;
90
97
  }
91
98
  case "update": {
92
99
  const transition = op.fromStatus !== op.toStatus ? ` (${op.fromStatus} → ${op.toStatus})` : "";
93
100
  return `Updated #${op.id}${transition}`;
94
101
  }
95
102
  case "batch_create":
96
- return `Created ${op.ids.length} tasks: ${op.ids.map((id) => `#${id}`).join(", ")}`;
103
+ return `${formatReplacePrefix(op.replacedCount)}Created ${op.ids.length} tasks: ${op.ids.map((id) => `#${id}`).join(", ")}`;
97
104
  case "batch_update":
98
105
  return `Updated ${op.ids.length} tasks: ${op.ids.map((id) => `#${id}`).join(", ")}`;
99
106
  case "delete":
@@ -144,8 +151,28 @@ export function buildToolResult(
144
151
 
145
152
  function appendWorkflowReminder(text: string, op: Op, state: TaskState): string {
146
153
  if (op.kind === "error" || op.kind === "export") return text;
154
+ const lines = [text];
155
+ if (op.kind === "create" || op.kind === "batch_create") {
156
+ lines.push(
157
+ "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.",
158
+ );
159
+ const createdIds = new Set(op.kind === "create" ? [op.taskId] : op.ids);
160
+ const hasOlderUnfinished = !op.replacedCount && state.tasks.some((task) => {
161
+ if (createdIds.has(task.id)) return false;
162
+ return task.status !== "completed" && task.status !== "deleted";
163
+ });
164
+ if (hasOlderUnfinished) {
165
+ lines.push(
166
+ "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.",
167
+ );
168
+ }
169
+ }
147
170
  const hasPending = state.tasks.some((task) => task.status === "pending");
148
171
  const hasInProgress = state.tasks.some((task) => task.status === "in_progress");
149
- if (!hasPending || hasInProgress) return text;
150
- return `${text}\n\nReminder: pending todos exist but none is in_progress. Before starting work, call todo update on exactly one task with status in_progress and activeForm.`;
172
+ if (hasPending && !hasInProgress) {
173
+ lines.push(
174
+ "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.",
175
+ );
176
+ }
177
+ return lines.join("\n\n");
151
178
  }
@@ -25,6 +25,7 @@ export const MSG_NO_TODOS = "No todos yet. Ask the agent to add some!";
25
25
 
26
26
  export type TaskStatus = "pending" | "in_progress" | "deferred" | "completed" | "deleted";
27
27
  export type TaskPriority = "low" | "medium" | "high" | "urgent";
28
+ export type TodoThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
28
29
 
29
30
  export type TaskAction = "create" | "update" | "batch_create" | "batch_update" | "list" | "get" | "delete" | "clear" | "export" | "import";
30
31
 
@@ -35,6 +36,7 @@ export interface Task {
35
36
  activeForm?: string;
36
37
  status: TaskStatus;
37
38
  priority?: TaskPriority;
39
+ thinking?: TodoThinkingLevel;
38
40
  parentId?: number;
39
41
  blockedBy?: number[];
40
42
  tags?: string[];
@@ -68,6 +70,7 @@ export interface TaskMutationParams {
68
70
  activeForm?: string;
69
71
  status?: TaskStatus;
70
72
  priority?: TaskPriority;
73
+ thinking?: TodoThinkingLevel;
71
74
  parentId?: number | null;
72
75
  clearParent?: boolean;
73
76
  blockedBy?: number[];
@@ -113,6 +116,11 @@ export const TodoParamsSchema = Type.Object({
113
116
  description: "Task priority (create/update) or list/export filter (list/export)",
114
117
  }),
115
118
  ),
119
+ thinking: Type.Optional(
120
+ StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, {
121
+ description: "Per-task thinking level used when todoThinking is enabled and this task is in_progress",
122
+ }),
123
+ ),
116
124
  parentId: Type.Optional(
117
125
  Type.Number({
118
126
  description: "Parent task id for hierarchy/subtasks (create/update); must refer to a non-deleted task",
@@ -182,7 +190,7 @@ export const TodoParamsSchema = Type.Object({
182
190
  }),
183
191
  ),
184
192
  content: Type.Optional(Type.String({ description: "Import content for action=import" })),
185
- replace: Type.Optional(Type.Boolean({ description: "For import, replace existing tasks instead of appending. Default: false." })),
193
+ replace: Type.Optional(Type.Boolean({ description: "For import/create/batch_create, replace existing tasks instead of appending. Use batch_create with replace:true when starting a new plan that supersedes old unfinished todos. Default: false." })),
186
194
  });
187
195
 
188
196
  export type TodoParams = Static<typeof TodoParamsSchema>;
@@ -11,8 +11,9 @@ export { formatStatusLabel };
11
11
  export function formatCommandTaskLine(t: Task, glyph: string): string {
12
12
  const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
13
13
  const priority = t.priority ? ` (${t.priority})` : "";
14
+ const thinking = t.thinking ? ` {thinking:${t.thinking}}` : "";
14
15
  const parent = t.parentId !== undefined ? ` ↳ #${t.parentId}` : "";
15
16
  const block = t.blockedBy?.length ? ` ⛓ ${t.blockedBy.map((id) => `#${id}`).join(",")}` : "";
16
17
  const tags = t.tags?.length ? ` ${t.tags.map((tag) => `#${tag}`).join(" ")}` : "";
17
- return ` ${glyph} #${t.id} ${t.subject}${priority}${form}${parent}${block}${tags}`;
18
+ return ` ${glyph} #${t.id} ${t.subject}${priority}${thinking}${form}${parent}${block}${tags}`;
18
19
  }
@@ -249,7 +249,7 @@ export const REPO_DISCOVERY_TOOL_NAMES = REPO_DISCOVERY_TOOLS.map((tool) => tool
249
249
  export const TODO_TOOL_DESCRIPTION: ToolDescription = {
250
250
  name: "todo",
251
251
  label: "Todo",
252
- description: "Track and keep in sync non-trivial multi-step work as todos. Actions: create, update, batch_create, batch_update, list, get, delete, clear, export, import. Supports priorities, tags, parent/subtask hierarchy, blockers, deferred out-of-scope items, and dependencies; skip trivial or chat-only requests. Resynchronize the plan when requirements are added, canceled, or become obsolete, whether from user input or discovered facts. Keep exactly one task in_progress and complete it only after verification.",
252
+ description: "Track and keep in sync non-trivial multi-step work as todos. Actions: create, update, batch_create, batch_update, list, get, delete, clear, export, import. Supports priorities, tags, parent/subtask hierarchy, blockers, deferred out-of-scope items, dependencies, and replace:true on create/batch_create/import for intentionally replacing an obsolete plan; skip trivial or chat-only requests. Resynchronize the plan when requirements are added, canceled, or become obsolete, whether from user input or discovered facts. Keep exactly one task in_progress and complete it only after verification.",
253
253
  promptSnippet: "Track/sync non-trivial multi-step work; resync when requirements or discoveries change the plan; keep one task in_progress",
254
254
  promptGuidelines: [
255
255
  "Use `todo` for complex work with 3+ steps, explicit user task lists, or new non-trivial requirements. Skip single trivial tasks and purely conversational requests.",
@@ -262,6 +262,7 @@ export const TODO_TOOL_DESCRIPTION: ToolDescription = {
262
262
  "Before giving a final response for work that used todos, ensure every visible todo is completed, deferred, or intentionally still in_progress with a blocker/explanation.",
263
263
  "Keep subjects short and imperative; put details in description only when needed. Use priority, tags, and parentId for large plans; use blockedBy on create and addBlockedBy/removeBlockedBy on update for dependencies.",
264
264
  "Use batch_create/batch_update for large explicit plans, but still keep exactly one visible task in_progress unless the user asks otherwise.",
265
+ "When starting a new plan that supersedes existing unfinished todos, use batch_create with replace:true instead of appending; only omit replace when intentionally extending the current plan.",
265
266
  "list hides deleted tombstones unless includeDeleted:true; pass status, priority, tag, or blockedOnly only when you need a filtered list.",
266
267
  "Use export/import for handoff or plan migration; import with replace:true only when the user explicitly wants to overwrite the current todo state.",
267
268
  "When every visible todo is completed, todo state clears automatically; do not call clear afterward just to remove completed tasks.",
@@ -1,4 +1,4 @@
1
- import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -37,7 +37,10 @@ async function readOpenCodeAuth(): Promise<{ authData: AuthData; error?: string
37
37
 
38
38
  async function readPiAuth(): Promise<PiAuthData> {
39
39
  try {
40
- const content = await readFile(join(getAgentDir(), "auth.json"), "utf-8");
40
+ const authPath = process.env.NODE_ENV === "test" && process.env.PI_TOOLS_SUITE_TEST_AUTH_PATH
41
+ ? process.env.PI_TOOLS_SUITE_TEST_AUTH_PATH
42
+ : join(homedir(), ".pi", "agent", "auth.json");
43
+ const content = await readFile(authPath, "utf-8");
41
44
  return JSON.parse(content) as PiAuthData;
42
45
  } catch {
43
46
  return {};
@@ -7,7 +7,6 @@
7
7
  * [同步]: usage.ts, types.ts, utils.ts
8
8
  */
9
9
 
10
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
10
  import { readFile } from "node:fs/promises";
12
11
  import { homedir } from "node:os";
13
12
  import { join } from "node:path";
@@ -97,7 +96,9 @@ function getAntigravityAccountsPath(): string {
97
96
  }
98
97
 
99
98
  function getPiAuthPath(): string {
100
- return join(getAgentDir(), "auth.json");
99
+ return process.env.NODE_ENV === "test" && process.env.PI_TOOLS_SUITE_TEST_AUTH_PATH
100
+ ? process.env.PI_TOOLS_SUITE_TEST_AUTH_PATH
101
+ : join(homedir(), ".pi", "agent", "auth.json");
101
102
  }
102
103
 
103
104
  function splitPiRefresh(refresh: string): AntigravityAccount | null {
@@ -156,14 +157,8 @@ async function readAntigravityAccounts(): Promise<AntigravityAccount[]> {
156
157
 
157
158
  const GOOGLE_TOKEN_REFRESH_URL = "https://oauth2.googleapis.com/token";
158
159
 
159
- const GOOGLE_CLIENT_ID =
160
- process.env.PIX_ANTIGRAVITY_GOOGLE_CLIENT_ID ??
161
- process.env.ANTIGRAVITY_GOOGLE_CLIENT_ID ??
162
- "";
163
- const GOOGLE_CLIENT_SECRET =
164
- process.env.PIX_ANTIGRAVITY_GOOGLE_CLIENT_SECRET ??
165
- process.env.ANTIGRAVITY_GOOGLE_CLIENT_SECRET ??
166
- "";
160
+ const GOOGLE_CLIENT_ID = "";
161
+ const GOOGLE_CLIENT_SECRET = "";
167
162
 
168
163
  // ============================================================================
169
164
  // 工具函数
@@ -258,9 +253,7 @@ async function refreshAccessToken(
258
253
  refreshToken: string,
259
254
  ): Promise<{ access_token: string; expires_in: number }> {
260
255
  if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
261
- throw new Error(
262
- "Antigravity Google OAuth credentials are not configured; set PIX_ANTIGRAVITY_GOOGLE_CLIENT_ID and PIX_ANTIGRAVITY_GOOGLE_CLIENT_SECRET.",
263
- );
256
+ throw new Error("Antigravity Google OAuth credentials are not bundled.");
264
257
  }
265
258
 
266
259
  const params = new URLSearchParams({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,10 @@
16
16
  },
17
17
  "description": "List of disabled module names (e.g. ['lsp', 'prompt-commands'])."
18
18
  },
19
+ "todoThinking": {
20
+ "type": "boolean",
21
+ "description": "Enable per-todo thinking levels and automatic thinking switch/restore when tasks become in-progress/completed."
22
+ },
19
23
  "terminalBell": {
20
24
  "type": "object",
21
25
  "properties": {