@phi-code-admin/phi-code 0.82.3 → 0.84.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/docs/usage.md +1 -1
  3. package/extensions/phi/btw/LICENSE +21 -0
  4. package/extensions/phi/btw/btw-ui.ts +238 -0
  5. package/extensions/phi/btw/btw.ts +363 -0
  6. package/extensions/phi/btw/index.ts +17 -0
  7. package/extensions/phi/btw/prompts/btw-system.txt +9 -0
  8. package/extensions/phi/chrome/LICENSE +21 -0
  9. package/extensions/phi/chrome/browser-extension/manifest.json +26 -0
  10. package/extensions/phi/chrome/browser-extension/service_worker.js +2388 -0
  11. package/extensions/phi/chrome/browser-extension/snapshot_injected.js +677 -0
  12. package/extensions/phi/chrome/index.ts +1799 -0
  13. package/extensions/phi/goal/LICENSE +21 -0
  14. package/extensions/phi/goal/index.ts +784 -0
  15. package/extensions/phi/mcp/LICENSE +21 -0
  16. package/extensions/phi/mcp/callback-server.ts +263 -0
  17. package/extensions/phi/mcp/config.ts +195 -0
  18. package/extensions/phi/mcp/errors.ts +35 -0
  19. package/extensions/phi/mcp/index.ts +376 -0
  20. package/extensions/phi/mcp/oauth-provider.ts +367 -0
  21. package/extensions/phi/mcp/server-manager.ts +464 -0
  22. package/extensions/phi/mcp/tool-bridge.ts +494 -0
  23. package/extensions/phi/todo/LICENSE +21 -0
  24. package/extensions/phi/todo/config.ts +14 -0
  25. package/extensions/phi/todo/index.ts +113 -0
  26. package/extensions/phi/todo/locales/de.json +17 -0
  27. package/extensions/phi/todo/locales/en.json +15 -0
  28. package/extensions/phi/todo/locales/es.json +17 -0
  29. package/extensions/phi/todo/locales/fr.json +17 -0
  30. package/extensions/phi/todo/locales/pt-BR.json +17 -0
  31. package/extensions/phi/todo/locales/pt.json +17 -0
  32. package/extensions/phi/todo/locales/ru.json +17 -0
  33. package/extensions/phi/todo/locales/uk.json +17 -0
  34. package/extensions/phi/todo/rpiv-config/config.ts +223 -0
  35. package/extensions/phi/todo/rpiv-config/index.ts +12 -0
  36. package/extensions/phi/todo/state/i18n-bridge.ts +65 -0
  37. package/extensions/phi/todo/state/invariants.ts +20 -0
  38. package/extensions/phi/todo/state/replay.ts +38 -0
  39. package/extensions/phi/todo/state/selectors.ts +107 -0
  40. package/extensions/phi/todo/state/state-reducer.ts +187 -0
  41. package/extensions/phi/todo/state/state.ts +18 -0
  42. package/extensions/phi/todo/state/store.ts +54 -0
  43. package/extensions/phi/todo/state/task-graph.ts +57 -0
  44. package/extensions/phi/todo/todo-overlay.ts +194 -0
  45. package/extensions/phi/todo/todo.ts +146 -0
  46. package/extensions/phi/todo/tool/response-envelope.ts +94 -0
  47. package/extensions/phi/todo/tool/types.ts +128 -0
  48. package/extensions/phi/todo/view/format.ts +162 -0
  49. package/package.json +4 -2
@@ -0,0 +1,187 @@
1
+ import type { Task, TaskAction, TaskMutationParams, TaskStatus } from "../tool/types.js";
2
+ import { isTransitionValid } from "./invariants.js";
3
+ import type { TaskState } from "./state.js";
4
+ import { detectCycle } from "./task-graph.js";
5
+
6
+ /**
7
+ * Reducer outcome. Closed tagged union — adding a new action requires extending
8
+ * this union AND the response-envelope's `formatContent` switch (compiler-
9
+ * enforced exhaustive). Mirrors the `Effect` pattern in
10
+ * `packages/rpiv-ask-user-question/state/state-reducer.ts:14-30`.
11
+ *
12
+ * `error` carries the message in-band so callers can pattern-match on
13
+ * `op.kind === "error"` without a side-channel boolean.
14
+ */
15
+ export type Op =
16
+ | { kind: "create"; taskId: number }
17
+ | { kind: "update"; id: number; fromStatus: TaskStatus; toStatus: TaskStatus }
18
+ | { kind: "delete"; id: number; subject: string }
19
+ | { kind: "list"; statusFilter?: TaskStatus; includeDeleted: boolean }
20
+ | { kind: "get"; task: Task }
21
+ | { kind: "clear"; count: number }
22
+ | { kind: "error"; message: string };
23
+
24
+ export interface ApplyResult {
25
+ state: TaskState;
26
+ op: Op;
27
+ }
28
+
29
+ function errorResult(state: TaskState, message: string): ApplyResult {
30
+ return { state, op: { kind: "error", message } };
31
+ }
32
+
33
+ /**
34
+ * Pure reducer: (state, action, params) → (state, op). Mirrors the
35
+ * `applyTaskMutation` of pre-refactor `todo.ts` minus content/details
36
+ * formatting; the response envelope (`tool/response-envelope.ts`) owns
37
+ * formatting, the store (`state/store.ts`) owns commit.
38
+ *
39
+ * Validation is in-line: structural guards (`subject required`, `id required`,
40
+ * `at least one mutable field`) plus state-aware checks (transition legality,
41
+ * dangling/deleted blockedBy, self-block, cycles). Decision: validation stays
42
+ * in-reducer — see Plan §Decisions §Decision 2.
43
+ */
44
+ export function applyTaskMutation(state: TaskState, action: TaskAction, params: TaskMutationParams): ApplyResult {
45
+ switch (action) {
46
+ case "create": {
47
+ if (!params.subject?.trim()) {
48
+ return errorResult(state, "subject required for create");
49
+ }
50
+ if (params.blockedBy?.length) {
51
+ for (const dep of params.blockedBy) {
52
+ const depTask = state.tasks.find((t) => t.id === dep);
53
+ if (!depTask) return errorResult(state, `blockedBy: #${dep} not found`);
54
+ if (depTask.status === "deleted") return errorResult(state, `blockedBy: #${dep} is deleted`);
55
+ }
56
+ }
57
+ const newTask: Task = {
58
+ id: state.nextId,
59
+ subject: params.subject,
60
+ status: "pending",
61
+ };
62
+ if (params.description) newTask.description = params.description;
63
+ if (params.activeForm) newTask.activeForm = params.activeForm;
64
+ if (params.blockedBy?.length) newTask.blockedBy = [...params.blockedBy];
65
+ if (params.owner) newTask.owner = params.owner;
66
+ if (params.metadata) newTask.metadata = { ...params.metadata };
67
+
68
+ const newTasks = [...state.tasks, newTask];
69
+ return {
70
+ state: { tasks: newTasks, nextId: state.nextId + 1 },
71
+ op: { kind: "create", taskId: newTask.id },
72
+ };
73
+ }
74
+
75
+ case "update": {
76
+ if (params.id === undefined) return errorResult(state, "id required for update");
77
+ const idx = state.tasks.findIndex((t) => t.id === params.id);
78
+ if (idx === -1) return errorResult(state, `#${params.id} not found`);
79
+ const current = state.tasks[idx];
80
+
81
+ const hasMutation =
82
+ params.subject !== undefined ||
83
+ params.description !== undefined ||
84
+ params.activeForm !== undefined ||
85
+ params.status !== undefined ||
86
+ params.owner !== undefined ||
87
+ params.metadata !== undefined ||
88
+ (params.addBlockedBy && params.addBlockedBy.length > 0) ||
89
+ (params.removeBlockedBy && params.removeBlockedBy.length > 0);
90
+ if (!hasMutation) return errorResult(state, "update requires at least one mutable field");
91
+
92
+ let newStatus = current.status;
93
+ if (params.status !== undefined) {
94
+ if (!isTransitionValid(current.status, params.status)) {
95
+ return errorResult(state, `illegal transition ${current.status} → ${params.status}`);
96
+ }
97
+ newStatus = params.status;
98
+ }
99
+
100
+ let newBlockedBy = current.blockedBy ? [...current.blockedBy] : [];
101
+ if (params.removeBlockedBy?.length) {
102
+ const toRemove = new Set(params.removeBlockedBy);
103
+ newBlockedBy = newBlockedBy.filter((dep) => !toRemove.has(dep));
104
+ }
105
+ if (params.addBlockedBy?.length) {
106
+ for (const dep of params.addBlockedBy) {
107
+ if (dep === current.id) return errorResult(state, `cannot block #${current.id} on itself`);
108
+ const depTask = state.tasks.find((t) => t.id === dep);
109
+ if (!depTask) return errorResult(state, `addBlockedBy: #${dep} not found`);
110
+ if (depTask.status === "deleted") return errorResult(state, `addBlockedBy: #${dep} is deleted`);
111
+ if (!newBlockedBy.includes(dep)) newBlockedBy.push(dep);
112
+ }
113
+ if (detectCycle(state.tasks, current.id, newBlockedBy)) {
114
+ return errorResult(state, "addBlockedBy would create a cycle in the blockedBy graph");
115
+ }
116
+ }
117
+
118
+ let newMetadata = current.metadata;
119
+ if (params.metadata !== undefined) {
120
+ const merged: Record<string, unknown> = { ...(current.metadata ?? {}) };
121
+ for (const [k, v] of Object.entries(params.metadata)) {
122
+ if (v === null) delete merged[k];
123
+ else merged[k] = v;
124
+ }
125
+ newMetadata = Object.keys(merged).length ? merged : undefined;
126
+ }
127
+
128
+ const updated: Task = { ...current, status: newStatus };
129
+ if (params.subject !== undefined) updated.subject = params.subject;
130
+ if (params.description !== undefined) updated.description = params.description;
131
+ if (params.activeForm !== undefined) updated.activeForm = params.activeForm;
132
+ if (params.owner !== undefined) updated.owner = params.owner;
133
+ if (newBlockedBy.length) updated.blockedBy = newBlockedBy;
134
+ else delete updated.blockedBy;
135
+ if (newMetadata === undefined) delete updated.metadata;
136
+ else updated.metadata = newMetadata;
137
+
138
+ const newTasks = [...state.tasks];
139
+ newTasks[idx] = updated;
140
+ return {
141
+ state: { tasks: newTasks, nextId: state.nextId },
142
+ op: { kind: "update", id: updated.id, fromStatus: current.status, toStatus: newStatus },
143
+ };
144
+ }
145
+
146
+ case "list": {
147
+ return {
148
+ state,
149
+ op: {
150
+ kind: "list",
151
+ includeDeleted: params.includeDeleted === true,
152
+ ...(params.status !== undefined ? { statusFilter: params.status } : {}),
153
+ },
154
+ };
155
+ }
156
+
157
+ case "get": {
158
+ if (params.id === undefined) return errorResult(state, "id required for get");
159
+ const task = state.tasks.find((t) => t.id === params.id);
160
+ if (!task) return errorResult(state, `#${params.id} not found`);
161
+ return { state, op: { kind: "get", task } };
162
+ }
163
+
164
+ case "delete": {
165
+ if (params.id === undefined) return errorResult(state, "id required for delete");
166
+ const idx = state.tasks.findIndex((t) => t.id === params.id);
167
+ if (idx === -1) return errorResult(state, `#${params.id} not found`);
168
+ const current = state.tasks[idx];
169
+ if (current.status === "deleted") return errorResult(state, `#${current.id} is already deleted`);
170
+ const updated: Task = { ...current, status: "deleted" };
171
+ const newTasks = [...state.tasks];
172
+ newTasks[idx] = updated;
173
+ return {
174
+ state: { tasks: newTasks, nextId: state.nextId },
175
+ op: { kind: "delete", id: updated.id, subject: updated.subject },
176
+ };
177
+ }
178
+
179
+ case "clear": {
180
+ const count = state.tasks.length;
181
+ return {
182
+ state: { tasks: [], nextId: 1 },
183
+ op: { kind: "clear", count },
184
+ };
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,18 @@
1
+ import type { Task } from "../tool/types.js";
2
+
3
+ /**
4
+ * Canonical state for the todo tool. Single source of truth — both the reducer
5
+ * (`state/state-reducer.ts`) and the live store cell (`state/store.ts`) read
6
+ * this shape. Replay (`state/replay.ts`) returns a fresh `TaskState`; the
7
+ * lifecycle handlers in `index.ts` write it via `replaceState`.
8
+ *
9
+ * The shape is intentionally minimal — no derived caches or runtime cells.
10
+ * Selectors in `state/selectors.ts` are pure of `TaskState` and own all
11
+ * derivations (visible/grouped/counted/etc).
12
+ */
13
+ export interface TaskState {
14
+ tasks: Task[];
15
+ nextId: number;
16
+ }
17
+
18
+ export const EMPTY_STATE: TaskState = { tasks: [], nextId: 1 };
@@ -0,0 +1,54 @@
1
+ import type { Task } from "../tool/types.js";
2
+ import { EMPTY_STATE, type TaskState } from "./state.js";
3
+
4
+ /**
5
+ * Module-level live state cell. Pre-refactor this lived as bare `tasks` /
6
+ * `nextId` consts in `todo.ts`; centralizing here keeps the store as the
7
+ * single mutation seam and lets the reducer remain pure.
8
+ */
9
+ let state: TaskState = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
10
+
11
+ /**
12
+ * Live tasks accessor. Returned `readonly Task[]` so callers (overlay render
13
+ * hook, `/todos` command, `renderCall` subject lookup) cannot mutate the live
14
+ * cell. Consumers must not cast back.
15
+ */
16
+ export function getTodos(): readonly Task[] {
17
+ return state.tasks;
18
+ }
19
+
20
+ export function getNextId(): number {
21
+ return state.nextId;
22
+ }
23
+
24
+ /** Snapshot accessor used by reducer callers to pass canonical state in. */
25
+ export function getState(): TaskState {
26
+ return state;
27
+ }
28
+
29
+ /**
30
+ * Replay seam. Lifecycle handlers in `index.ts` call this on
31
+ * `session_start` / `session_compact` / `session_tree` after
32
+ * `replayFromBranch` decodes the latest snapshot.
33
+ */
34
+ export function replaceState(next: TaskState): void {
35
+ state = next;
36
+ }
37
+
38
+ /**
39
+ * Post-reducer commit seam. Tool execute() calls this with the reducer's
40
+ * `state` output to publish the new canonical state to live readers (overlay,
41
+ * `/todos`, renderCall).
42
+ */
43
+ export function commitState(next: TaskState): void {
44
+ state = next;
45
+ }
46
+
47
+ /**
48
+ * Test-setup reset. Wired into the global `test/setup.ts` `beforeEach` via
49
+ * the existing `__resetState` import path. Name preserved verbatim — see
50
+ * Plan §Decisions §Decision 7.
51
+ */
52
+ export function __resetState(): void {
53
+ state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
54
+ }
@@ -0,0 +1,57 @@
1
+ import type { Task } from "../tool/types.js";
2
+
3
+ /**
4
+ * Detect whether merging `newBlockedBy` into `taskId`'s `blockedBy` set would
5
+ * introduce a cycle in the dependency graph.
6
+ *
7
+ * Pure of any module state; takes the existing `taskList` and the proposed
8
+ * additions explicitly so the reducer can ask "would this update cycle?"
9
+ * without mutating state first.
10
+ */
11
+ export function detectCycle(taskList: readonly Task[], taskId: number, newBlockedBy: readonly number[]): boolean {
12
+ const edges = new Map<number, number[]>();
13
+ for (const t of taskList) {
14
+ if (t.id === taskId) {
15
+ const merged = new Set([...(t.blockedBy ?? []), ...newBlockedBy]);
16
+ edges.set(t.id, [...merged]);
17
+ } else {
18
+ edges.set(t.id, t.blockedBy ? [...t.blockedBy] : []);
19
+ }
20
+ }
21
+
22
+ const visiting = new Set<number>();
23
+ const visited = new Set<number>();
24
+ const hasCycleFrom = (node: number): boolean => {
25
+ if (visiting.has(node)) return true;
26
+ if (visited.has(node)) return false;
27
+ visiting.add(node);
28
+ for (const nb of edges.get(node) ?? []) {
29
+ if (hasCycleFrom(nb)) return true;
30
+ }
31
+ visiting.delete(node);
32
+ visited.add(node);
33
+ return false;
34
+ };
35
+
36
+ for (const node of edges.keys()) {
37
+ if (hasCycleFrom(node)) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /**
43
+ * Build the inverse adjacency map: for each task `T`, which other tasks list
44
+ * `T` in their `blockedBy`. Consumed by `selectShowTaskIds` (overlay gating)
45
+ * and the `get` action's "blocks: #x, #y" suffix line.
46
+ */
47
+ export function deriveBlocks(taskList: readonly Task[]): Map<number, number[]> {
48
+ const blocks = new Map<number, number[]>();
49
+ for (const t of taskList) {
50
+ for (const dep of t.blockedBy ?? []) {
51
+ const arr = blocks.get(dep) ?? [];
52
+ arr.push(t.id);
53
+ blocks.set(dep, arr);
54
+ }
55
+ }
56
+ return blocks;
57
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * todo-overlay.ts — Persistent widget showing todo list above the editor.
3
+ *
4
+ * Lifecycle controller for Pi's `setWidget` contract: factory-form
5
+ * registration in widgetContainerAbove, register-once + requestRender()
6
+ * refresh, 12-line collapse-not-scroll (plus a trailing spacer row, so the
7
+ * widget renders up to 13 lines), auto-hide when empty.
8
+ *
9
+ * Reads live state via `getState()` at render time — NEVER `replayFromBranch`
10
+ * from `tool_execution_end` (branch is stale; `message_end` runs after).
11
+ */
12
+
13
+ import type { ExtensionUIContext, Theme } from "phi-code";
14
+ import { type TUI, truncateToWidth } from "phi-code-tui";
15
+ import { formatStatusLabel, t } from "./state/i18n-bridge.js";
16
+ import { selectHasActive, selectOverlayLayout, selectShowTaskIds, selectTodoCounts } from "./state/selectors.js";
17
+ import { getState } from "./state/store.js";
18
+ import { formatOverlayTaskLine } from "./view/format.js";
19
+
20
+ const WIDGET_KEY = "rpiv-todos";
21
+ // Budget for content rows (heading + tasks/summary). The rendered widget is
22
+ // one line taller — withTrailingSpacer() appends a blank row below the panel.
23
+ const MAX_WIDGET_LINES = 12;
24
+
25
+ // English fallbacks for localized overlay chrome strings.
26
+ const OVERLAY_HEADING = "Todos";
27
+ const OVERLAY_MORE = "more";
28
+
29
+ export class TodoOverlay {
30
+ private uiCtx: ExtensionUIContext | undefined;
31
+ private widgetRegistered = false;
32
+ private tui: TUI | undefined;
33
+ private completedTaskIdsPendingHide = new Set<number>();
34
+ private hiddenCompletedTaskIds = new Set<number>();
35
+ private lastNextId: number | undefined;
36
+
37
+ setUICtx(ctx: ExtensionUIContext): void {
38
+ // Identity-compare so repeat session_start handlers are idempotent;
39
+ // on identity change (/reload) invalidate so update() re-registers.
40
+ if (ctx !== this.uiCtx) {
41
+ this.uiCtx = ctx;
42
+ this.widgetRegistered = false;
43
+ this.tui = undefined;
44
+ }
45
+ }
46
+
47
+ update(): void {
48
+ if (!this.uiCtx) return;
49
+ const snapshot = this.getSnapshot();
50
+ const visible = this.selectOverlayTasks(snapshot);
51
+
52
+ if (visible.length === 0) {
53
+ if (this.widgetRegistered) {
54
+ this.uiCtx.setWidget(WIDGET_KEY, undefined);
55
+ this.widgetRegistered = false;
56
+ this.tui = undefined;
57
+ }
58
+ return;
59
+ }
60
+
61
+ if (!this.widgetRegistered) {
62
+ this.uiCtx.setWidget(
63
+ WIDGET_KEY,
64
+ (tui, theme) => {
65
+ this.tui = tui;
66
+ return {
67
+ render: (width: number) => this.renderWidget(theme, width),
68
+ invalidate: () => {
69
+ this.widgetRegistered = false;
70
+ this.tui = undefined;
71
+ },
72
+ };
73
+ },
74
+ { placement: "aboveEditor" },
75
+ );
76
+ this.widgetRegistered = true;
77
+ } else {
78
+ this.tui?.requestRender();
79
+ }
80
+ }
81
+
82
+ resetCompletedDisplayState(): void {
83
+ this.completedTaskIdsPendingHide.clear();
84
+ this.hiddenCompletedTaskIds.clear();
85
+ this.lastNextId = undefined;
86
+ }
87
+
88
+ hideCompletedTasksFromPreviousTurn(): void {
89
+ if (this.completedTaskIdsPendingHide.size === 0) return;
90
+ for (const taskId of this.completedTaskIdsPendingHide) {
91
+ this.hiddenCompletedTaskIds.add(taskId);
92
+ }
93
+ this.completedTaskIdsPendingHide.clear();
94
+ this.tui?.requestRender();
95
+ }
96
+
97
+ private getSnapshot() {
98
+ const state = getState();
99
+ if (this.lastNextId !== undefined && state.nextId < this.lastNextId) {
100
+ this.resetCompletedDisplayState();
101
+ }
102
+ this.lastNextId = state.nextId;
103
+ const completedTaskIds = new Set(
104
+ state.tasks.filter((task) => task.status === "completed").map((task) => task.id),
105
+ );
106
+ for (const taskId of this.completedTaskIdsPendingHide) {
107
+ if (!completedTaskIds.has(taskId)) this.completedTaskIdsPendingHide.delete(taskId);
108
+ }
109
+ for (const taskId of this.hiddenCompletedTaskIds) {
110
+ if (!completedTaskIds.has(taskId)) this.hiddenCompletedTaskIds.delete(taskId);
111
+ }
112
+ return { tasks: [...state.tasks], nextId: state.nextId };
113
+ }
114
+
115
+ private selectOverlayTasks(snapshot: ReturnType<TodoOverlay["getSnapshot"]>) {
116
+ return snapshot.tasks.filter((task) => task.status !== "deleted" && !this.shouldHideCompletedTask(task));
117
+ }
118
+
119
+ private shouldHideCompletedTask(task: ReturnType<TodoOverlay["getSnapshot"]>["tasks"][number]): boolean {
120
+ return task.status === "completed" && this.hiddenCompletedTaskIds.has(task.id);
121
+ }
122
+
123
+ private renderWidget(theme: Theme, width: number): string[] {
124
+ const snapshot = this.getSnapshot();
125
+ const overlayTasks = this.selectOverlayTasks(snapshot);
126
+ if (overlayTasks.length === 0) return [];
127
+
128
+ const overlayState = { tasks: overlayTasks, nextId: snapshot.nextId };
129
+ const truncate = (line: string): string => truncateToWidth(line, width, "…");
130
+ const counts = selectTodoCounts(overlayState);
131
+ const hasActive = selectHasActive(overlayState);
132
+ const showIds = selectShowTaskIds(overlayState);
133
+
134
+ const headingColor = hasActive ? "accent" : "dim";
135
+ const headingIcon = hasActive ? "●" : "○";
136
+ const headingText = `${t("overlay.heading", OVERLAY_HEADING)} (${counts.completed}/${counts.total})`;
137
+ const heading = truncate(`${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, headingText)}`);
138
+
139
+ const lines: string[] = [heading];
140
+ const layout = selectOverlayLayout(overlayState, MAX_WIDGET_LINES - 1);
141
+ for (const task of layout.visible) {
142
+ lines.push(truncate(`${theme.fg("dim", "├─")} ${formatOverlayTaskLine(task, theme, showIds)}`));
143
+ }
144
+
145
+ const newlyDisplayedCompletedTaskIds = overlayTasks
146
+ .filter(
147
+ (task) =>
148
+ task.status === "completed" &&
149
+ !this.completedTaskIdsPendingHide.has(task.id) &&
150
+ !this.hiddenCompletedTaskIds.has(task.id),
151
+ )
152
+ .map((task) => task.id);
153
+ for (const taskId of newlyDisplayedCompletedTaskIds) {
154
+ this.completedTaskIdsPendingHide.add(taskId);
155
+ }
156
+
157
+ if (layout.hiddenCompleted === 0 && layout.truncatedTail === 0) {
158
+ const last = lines.length - 1;
159
+ lines[last] = lines[last].replace("├─", "└─");
160
+ return this.withTrailingSpacer(lines);
161
+ }
162
+
163
+ const totalHidden = layout.hiddenCompleted + layout.truncatedTail;
164
+ const overflowParts: string[] = [];
165
+ if (layout.hiddenCompleted > 0) overflowParts.push(`${layout.hiddenCompleted} ${formatStatusLabel("completed")}`);
166
+ if (layout.truncatedTail > 0) overflowParts.push(`${layout.truncatedTail} ${formatStatusLabel("pending")}`);
167
+ const more = t("overlay.more", OVERLAY_MORE);
168
+ const summary =
169
+ overflowParts.length > 0 ? `+${totalHidden} ${more} (${overflowParts.join(", ")})` : `+${totalHidden} ${more}`;
170
+ lines.push(truncate(`${theme.fg("dim", "└─")} ${theme.fg("dim", summary)}`));
171
+ return this.withTrailingSpacer(lines);
172
+ }
173
+
174
+ /**
175
+ * Append a trailing blank line so the overlay isn't flush against the
176
+ * editor box. Pi's host adds a leading spacer above the widget but none
177
+ * below, which leaves the last "└─" row (or the "+N more" summary) glued
178
+ * to the input box. The empty string gives the "Todos" panel a little
179
+ * breathing room.
180
+ */
181
+ private withTrailingSpacer(lines: string[]): string[] {
182
+ if (lines.length === 0) return lines;
183
+ lines.push("");
184
+ return lines;
185
+ }
186
+
187
+ dispose(): void {
188
+ if (this.uiCtx) this.uiCtx.setWidget(WIDGET_KEY, undefined);
189
+ this.widgetRegistered = false;
190
+ this.tui = undefined;
191
+ this.uiCtx = undefined;
192
+ this.resetCompletedDisplayState();
193
+ }
194
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * todo tool + /todos command — thin registration shell.
3
+ *
4
+ * Tool/command identity, schema, types, reducer, store, replay, response
5
+ * envelope, selectors, and view formatters live in the layered modules under
6
+ * `tool/`, `state/`, and `view/`. This file is the package-root registration
7
+ * surface — it mirrors `packages/rpiv-ask-user-question/ask-user-question.ts`
8
+ * which keeps the tool registration at the package root.
9
+ *
10
+ * Public re-exports below preserve the pre-refactor import surface so that
11
+ * `index.ts`, `todo-overlay.ts`, and the global `test/setup.ts` `beforeEach`
12
+ * continue to import from `./todo.js`.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "phi-code";
16
+ import { loadConfig, validateGuidanceFields } from "./config.js";
17
+ import { formatStatusLabel, t } from "./state/i18n-bridge.js";
18
+ import { replayFromBranch } from "./state/replay.js";
19
+ import { selectTasksByStatus, selectTodoCounts, selectVisibleTasks } from "./state/selectors.js";
20
+ import { applyTaskMutation } from "./state/state-reducer.js";
21
+ import { commitState, getState, replaceState } from "./state/store.js";
22
+ import { buildToolResult } from "./tool/response-envelope.js";
23
+ import {
24
+ COMMAND_NAME,
25
+ ERR_REQUIRES_INTERACTIVE,
26
+ MSG_NO_TODOS,
27
+ type TaskMutationParams,
28
+ TOOL_LABEL,
29
+ TOOL_NAME,
30
+ TodoParamsSchema,
31
+ } from "./tool/types.js";
32
+ import { formatCommandTaskLine, renderTodoCall, renderTodoResult } from "./view/format.js";
33
+
34
+ // English fallbacks for localized /todos section headers — the box-drawing
35
+ // decoration is part of the localized string so translators can adjust spacing.
36
+ const SECTION_PENDING = "── Pending ──";
37
+ const SECTION_IN_PROGRESS = "── In Progress ──";
38
+ const SECTION_COMPLETED = "── Completed ──";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Public re-exports — pre-refactor consumers (overlay, tests, index.ts) keep
42
+ // importing from `./todo.js`. New code may opt into deeper imports.
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export { isTransitionValid } from "./state/invariants.js";
46
+ export { applyTaskMutation } from "./state/state-reducer.js";
47
+ export { __resetState, getNextId, getTodos } from "./state/store.js";
48
+ export { deriveBlocks, detectCycle } from "./state/task-graph.js";
49
+ export type { Task, TaskAction, TaskDetails, TaskStatus } from "./tool/types.js";
50
+ export { TOOL_NAME } from "./tool/types.js";
51
+
52
+ /**
53
+ * Backward-compat replay shim. Pre-refactor `reconstructTodoState(ctx)`
54
+ * mutated module state directly; the new replay seam (`state/replay.ts`)
55
+ * returns a `TaskState` and the caller commits via `replaceState`.
56
+ */
57
+ export function reconstructTodoState(ctx: Parameters<typeof replayFromBranch>[0]): void {
58
+ replaceState(replayFromBranch(ctx));
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Tool registration
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export const DEFAULT_PROMPT_SNIPPET = "Manage a task list to track multi-step progress";
66
+ export const DEFAULT_PROMPT_GUIDELINES: string[] = [
67
+ "Use `todo` for complex work with 3+ steps, when the user gives you a list of tasks, or immediately after receiving new instructions to capture requirements. Skip it for single trivial tasks and purely conversational requests.",
68
+ "When starting any task, mark it in_progress BEFORE beginning work. Mark it completed IMMEDIATELY when done — never batch completions. Exactly one task should be in_progress at a time.",
69
+ "Never mark a task completed if tests are failing, the implementation is partial, or you hit unresolved errors — keep it in_progress and create a new task for the blocker instead.",
70
+ "Task status is a 4-state machine: pending → in_progress → completed, plus deleted as a tombstone. Pass activeForm (present-continuous label, e.g. 'researching existing tool') when marking in_progress.",
71
+ "Use blockedBy to express dependencies (A is blocked by B). On create, pass blockedBy as the initial set. On update, use addBlockedBy / removeBlockedBy (additive merge — do not resend the full array). Cycles are rejected.",
72
+ "list hides tombstoned (deleted) tasks by default; pass includeDeleted:true to see them. Pass status to filter by a single status.",
73
+ "Subject must be short and imperative (e.g. 'Research existing tool'); description is for long-form detail. activeForm is a present-continuous label shown while in_progress.",
74
+ ];
75
+
76
+ export function registerTodoTool(pi: ExtensionAPI): void {
77
+ const guidance = validateGuidanceFields(loadConfig().guidance);
78
+ pi.registerTool({
79
+ name: TOOL_NAME,
80
+ label: TOOL_LABEL,
81
+ description:
82
+ "Manage a task list for tracking multi-step progress. Actions: create (new task), update (change status/fields/dependencies), list (all tasks, optionally filtered by status), get (single task details), delete (tombstone), clear (reset all). Status: pending → in_progress → completed, plus deleted tombstone. Use this to plan and track multi-step work like research, design, and implementation.",
83
+ promptSnippet: guidance.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
84
+ promptGuidelines: guidance.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
85
+ parameters: TodoParamsSchema,
86
+
87
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
88
+ const result = applyTaskMutation(getState(), params.action, params as TaskMutationParams);
89
+ commitState(result.state);
90
+ return buildToolResult(params.action, params as TaskMutationParams, result.state, result.op);
91
+ },
92
+
93
+ renderCall(args, theme, _context) {
94
+ return renderTodoCall(args as never, theme, getState());
95
+ },
96
+
97
+ renderResult(result, _opts, theme, _context) {
98
+ return renderTodoResult(result, theme);
99
+ },
100
+ });
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // /todos slash command
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export function registerTodosCommand(pi: ExtensionAPI): void {
108
+ pi.registerCommand(COMMAND_NAME, {
109
+ description: "Show all todos on the current branch, grouped by status",
110
+ handler: async (_args, ctx) => {
111
+ if (!ctx.hasUI) {
112
+ ctx.ui.notify(t("command.requires_interactive", ERR_REQUIRES_INTERACTIVE), "error");
113
+ return;
114
+ }
115
+ const state = getState();
116
+ const visible = selectVisibleTasks(state);
117
+ if (visible.length === 0) {
118
+ ctx.ui.notify(t("command.no_todos", MSG_NO_TODOS), "info");
119
+ return;
120
+ }
121
+ const groups = selectTasksByStatus(state);
122
+ const counts = selectTodoCounts(state);
123
+
124
+ const header: string[] = [];
125
+ if (counts.completed > 0) header.push(`${counts.completed}/${counts.total} ${formatStatusLabel("completed")}`);
126
+ if (counts.inProgress > 0) header.push(`${counts.inProgress} ${formatStatusLabel("in_progress")}`);
127
+ if (counts.pending > 0) header.push(`${counts.pending} ${formatStatusLabel("pending")}`);
128
+
129
+ const lines: string[] = [header.join(" · ")];
130
+ if (groups.pending.length > 0) {
131
+ lines.push(t("command.section.pending", SECTION_PENDING));
132
+ for (const task of groups.pending) lines.push(formatCommandTaskLine(task, "○"));
133
+ }
134
+ if (groups.inProgress.length > 0) {
135
+ lines.push(t("command.section.in_progress", SECTION_IN_PROGRESS));
136
+ for (const task of groups.inProgress) lines.push(formatCommandTaskLine(task, "◐"));
137
+ }
138
+ if (groups.completed.length > 0) {
139
+ lines.push(t("command.section.completed", SECTION_COMPLETED));
140
+ for (const task of groups.completed) lines.push(formatCommandTaskLine(task, "✓"));
141
+ }
142
+
143
+ ctx.ui.notify(lines.join("\n"), "info");
144
+ },
145
+ });
146
+ }