@phi-code-admin/phi-code 0.83.0 → 0.84.1

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 (41) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/extensions/phi/btw/LICENSE +21 -0
  3. package/extensions/phi/btw/btw-ui.ts +238 -0
  4. package/extensions/phi/btw/btw.ts +363 -0
  5. package/extensions/phi/btw/index.ts +17 -0
  6. package/extensions/phi/btw/prompts/btw-system.txt +9 -0
  7. package/extensions/phi/chrome/LICENSE +21 -0
  8. package/extensions/phi/chrome/browser-extension/manifest.json +26 -0
  9. package/extensions/phi/chrome/browser-extension/service_worker.js +2388 -0
  10. package/extensions/phi/chrome/browser-extension/snapshot_injected.js +677 -0
  11. package/extensions/phi/chrome/index.ts +1799 -0
  12. package/extensions/phi/goal/LICENSE +21 -0
  13. package/extensions/phi/goal/index.ts +784 -0
  14. package/extensions/phi/todo/LICENSE +21 -0
  15. package/extensions/phi/todo/config.ts +14 -0
  16. package/extensions/phi/todo/index.ts +113 -0
  17. package/extensions/phi/todo/locales/de.json +17 -0
  18. package/extensions/phi/todo/locales/en.json +15 -0
  19. package/extensions/phi/todo/locales/es.json +17 -0
  20. package/extensions/phi/todo/locales/fr.json +17 -0
  21. package/extensions/phi/todo/locales/pt-BR.json +17 -0
  22. package/extensions/phi/todo/locales/pt.json +17 -0
  23. package/extensions/phi/todo/locales/ru.json +17 -0
  24. package/extensions/phi/todo/locales/uk.json +17 -0
  25. package/extensions/phi/todo/rpiv-config/config.ts +223 -0
  26. package/extensions/phi/todo/rpiv-config/index.ts +12 -0
  27. package/extensions/phi/todo/state/i18n-bridge.ts +65 -0
  28. package/extensions/phi/todo/state/invariants.ts +20 -0
  29. package/extensions/phi/todo/state/replay.ts +38 -0
  30. package/extensions/phi/todo/state/selectors.ts +107 -0
  31. package/extensions/phi/todo/state/state-reducer.ts +187 -0
  32. package/extensions/phi/todo/state/state.ts +18 -0
  33. package/extensions/phi/todo/state/store.ts +54 -0
  34. package/extensions/phi/todo/state/task-graph.ts +57 -0
  35. package/extensions/phi/todo/todo-overlay.ts +194 -0
  36. package/extensions/phi/todo/todo.ts +146 -0
  37. package/extensions/phi/todo/tool/response-envelope.ts +94 -0
  38. package/extensions/phi/todo/tool/types.ts +128 -0
  39. package/extensions/phi/todo/view/format.ts +162 -0
  40. package/package.json +1 -1
  41. package/scripts/postinstall.cjs +10 -5
@@ -0,0 +1,107 @@
1
+ import type { Task, TaskStatus } from "../tool/types.js";
2
+ import type { TaskState } from "./state.js";
3
+
4
+ /** Tasks excluding deleted tombstones — the canonical "what's visible". */
5
+ export function selectVisibleTasks(state: TaskState): readonly Task[] {
6
+ return state.tasks.filter((t) => t.status !== "deleted");
7
+ }
8
+
9
+ /**
10
+ * Group visible tasks by status. Iteration order at the call site uses
11
+ * (`completed`, `inProgress`, `pending`) to match the `/todos` header part
12
+ * order pinned by `todo.command.test.ts`.
13
+ */
14
+ export interface TasksByStatus {
15
+ pending: readonly Task[];
16
+ inProgress: readonly Task[];
17
+ completed: readonly Task[];
18
+ }
19
+ export function selectTasksByStatus(state: TaskState): TasksByStatus {
20
+ const visible = selectVisibleTasks(state);
21
+ return {
22
+ pending: visible.filter((t) => t.status === "pending"),
23
+ inProgress: visible.filter((t) => t.status === "in_progress"),
24
+ completed: visible.filter((t) => t.status === "completed"),
25
+ };
26
+ }
27
+
28
+ /** Total counts for the overlay heading (`Todos (n/m)`) and `/todos` header. */
29
+ export interface TodoCounts {
30
+ total: number;
31
+ pending: number;
32
+ inProgress: number;
33
+ completed: number;
34
+ }
35
+ export function selectTodoCounts(state: TaskState): TodoCounts {
36
+ const groups = selectTasksByStatus(state);
37
+ return {
38
+ total: groups.pending.length + groups.inProgress.length + groups.completed.length,
39
+ pending: groups.pending.length,
40
+ inProgress: groups.inProgress.length,
41
+ completed: groups.completed.length,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Whether any visible task carries a `blockedBy` reference. The overlay uses
47
+ * this to gate the `#id` prefix on per-task rows — without at least one
48
+ * `⛓ #N` suffix, the per-row id has no anchor.
49
+ */
50
+ export function selectShowTaskIds(state: TaskState): boolean {
51
+ return selectVisibleTasks(state).some((t) => t.blockedBy && t.blockedBy.length > 0);
52
+ }
53
+
54
+ /**
55
+ * Resolve a task's subject by id from the live state for renderCall's
56
+ * accent label. `undefined` when the id is unknown — caller falls back to
57
+ * `#id` plain rendering.
58
+ */
59
+ export function selectTaskSubjectById(state: TaskState, id: number): string | undefined {
60
+ return state.tasks.find((t) => t.id === id)?.subject;
61
+ }
62
+
63
+ /**
64
+ * Overlay layout decision. Encapsulates the "drop completed first, then
65
+ * truncate non-completed tail" rule pre-refactor lived in
66
+ * `todo-overlay.ts:144-188`. `budget` is the body-slot count (caller passes
67
+ * `MAX_WIDGET_LINES - 1` to reserve the heading row); on overflow the
68
+ * selector reserves one more slot internally for the summary row. Returns
69
+ * the visible task slice plus the overflow summary parts.
70
+ */
71
+ export interface OverlayLayout {
72
+ visible: readonly Task[];
73
+ hiddenCompleted: number;
74
+ truncatedTail: number;
75
+ }
76
+ export function selectOverlayLayout(state: TaskState, budget: number): OverlayLayout {
77
+ const all = selectVisibleTasks(state);
78
+ if (all.length <= budget) {
79
+ return { visible: all, hiddenCompleted: 0, truncatedTail: 0 };
80
+ }
81
+ const innerBudget = budget - 1;
82
+ const nonCompleted = all.filter((t) => t.status !== "completed");
83
+ const totalCompleted = all.length - nonCompleted.length;
84
+ if (nonCompleted.length <= innerBudget) {
85
+ const kept = new Set<Task>(nonCompleted);
86
+ for (const t of all) {
87
+ if (kept.size >= innerBudget) break;
88
+ if (t.status === "completed") kept.add(t);
89
+ }
90
+ const visible = all.filter((t) => kept.has(t));
91
+ const shownCompleted = visible.filter((t) => t.status === "completed").length;
92
+ return { visible, hiddenCompleted: totalCompleted - shownCompleted, truncatedTail: 0 };
93
+ }
94
+ const visible = nonCompleted.slice(0, innerBudget);
95
+ const truncatedTail = nonCompleted.length - innerBudget;
96
+ return { visible, hiddenCompleted: totalCompleted, truncatedTail };
97
+ }
98
+
99
+ /**
100
+ * Helper: whether any visible task is `pending` or `in_progress`. The overlay
101
+ * uses this to pick the heading icon (`accent`+`●` vs `dim`+`○`).
102
+ */
103
+ export function selectHasActive(state: TaskState): boolean {
104
+ return selectVisibleTasks(state).some((t) => t.status === "in_progress" || t.status === "pending");
105
+ }
106
+
107
+ export const ACTIVE_STATUSES: ReadonlySet<TaskStatus> = new Set(["pending", "in_progress"]);
@@ -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
+ }