@bumpyclock/pi-tasque 0.1.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.
@@ -0,0 +1,292 @@
1
+ import type {
2
+ Task,
3
+ TaskAction,
4
+ TaskMutationParams,
5
+ TaskStatus,
6
+ } from "../tool/types.js";
7
+ import { isTransitionValid } from "./invariants.js";
8
+ import type { TaskState } from "./state.js";
9
+ import { detectCycle } from "./task-graph.js";
10
+
11
+ export { EMPTY_STATE } from "./state.js";
12
+ export type { TaskState } from "./state.js";
13
+
14
+ export type Op =
15
+ | { kind: "create"; taskId: number }
16
+ | { kind: "update"; id: number; fromStatus: TaskStatus; toStatus: TaskStatus }
17
+ | { kind: "delete"; id: number; subject: string }
18
+ | { kind: "list"; statusFilter?: TaskStatus; includeDeleted: boolean }
19
+ | { kind: "get"; task: Task }
20
+ | { kind: "clear"; count: number }
21
+ | { kind: "error"; message: string };
22
+
23
+ export interface ApplyResult {
24
+ state: TaskState;
25
+ op: Op;
26
+ }
27
+
28
+ function errorResult(state: TaskState, message: string): ApplyResult {
29
+ return { state, op: { kind: "error", message } };
30
+ }
31
+
32
+ function isValidId(id: unknown): id is number {
33
+ return typeof id === "number" && Number.isFinite(id);
34
+ }
35
+
36
+ function findTask(tasks: readonly Task[], id: number): Task | undefined {
37
+ return tasks.find((task) => task.id === id);
38
+ }
39
+
40
+ function dedupeIds(ids: readonly number[]): number[] {
41
+ return [...new Set(ids)];
42
+ }
43
+
44
+ function validateBlockers(
45
+ state: TaskState,
46
+ taskId: number,
47
+ blockerIds: readonly number[],
48
+ fieldName: "blockedBy" | "addBlockedBy",
49
+ ): string | undefined {
50
+ for (const blockerId of blockerIds) {
51
+ if (blockerId === taskId) return `cannot block #${taskId} on itself`;
52
+ const blocker = findTask(state.tasks, blockerId);
53
+ if (!blocker) return `${fieldName}: #${blockerId} not found`;
54
+ if (blocker.status === "deleted")
55
+ return `${fieldName}: #${blockerId} is deleted`;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function validateRemovals(
61
+ state: TaskState,
62
+ blockerIds: readonly number[],
63
+ ): string | undefined {
64
+ for (const blockerId of blockerIds) {
65
+ if (!findTask(state.tasks, blockerId))
66
+ return `removeBlockedBy: #${blockerId} not found`;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function mergeMetadata(
72
+ current: Readonly<Record<string, unknown>> | undefined,
73
+ patch: Readonly<Record<string, unknown>>,
74
+ ): Record<string, unknown> | undefined {
75
+ const merged: Record<string, unknown> = { ...(current ?? {}) };
76
+ for (const [key, value] of Object.entries(patch)) {
77
+ if (value === null) delete merged[key];
78
+ else merged[key] = value;
79
+ }
80
+ return Object.keys(merged).length > 0 ? merged : undefined;
81
+ }
82
+
83
+ function assignOptionalTaskFields(
84
+ task: Task,
85
+ params: TaskMutationParams,
86
+ ): void {
87
+ if (params.description !== undefined) task.description = params.description;
88
+ if (params.activeForm !== undefined) task.activeForm = params.activeForm;
89
+ if (params.owner !== undefined) task.owner = params.owner;
90
+ }
91
+
92
+ /**
93
+ * Pure reducer: (state, action, params) -> { state, op }.
94
+ *
95
+ * The reducer owns validation and returns failures as in-band error ops so tool
96
+ * response code can preserve the same replay snapshot shape for success/error.
97
+ */
98
+ export function applyTaskMutation(
99
+ state: TaskState,
100
+ action: TaskAction,
101
+ params: TaskMutationParams,
102
+ ): ApplyResult {
103
+ switch (action) {
104
+ case "create": {
105
+ if (!params.subject?.trim()) {
106
+ return errorResult(state, "subject required for create");
107
+ }
108
+
109
+ const blockedBy = dedupeIds(params.blockedBy ?? []);
110
+ const blockerError = validateBlockers(
111
+ state,
112
+ state.nextId,
113
+ blockedBy,
114
+ "blockedBy",
115
+ );
116
+ if (blockerError) return errorResult(state, blockerError);
117
+
118
+ const task: Task = {
119
+ id: state.nextId,
120
+ subject: params.subject,
121
+ status: "pending",
122
+ };
123
+ assignOptionalTaskFields(task, params);
124
+ if (blockedBy.length > 0) task.blockedBy = blockedBy;
125
+ if (params.metadata !== undefined) task.metadata = { ...params.metadata };
126
+
127
+ const tasks = [...state.tasks, task];
128
+ if (detectCycle(tasks, task.id, blockedBy)) {
129
+ return errorResult(
130
+ state,
131
+ "blockedBy would create a cycle in the blockedBy graph",
132
+ );
133
+ }
134
+
135
+ return {
136
+ state: { tasks, nextId: state.nextId + 1 },
137
+ op: { kind: "create", taskId: task.id },
138
+ };
139
+ }
140
+
141
+ case "update": {
142
+ if (!isValidId(params.id))
143
+ return errorResult(state, "id required for update");
144
+ const index = state.tasks.findIndex((task) => task.id === params.id);
145
+ if (index === -1) return errorResult(state, `#${params.id} not found`);
146
+ const current = state.tasks[index];
147
+ if (!current) return errorResult(state, `#${params.id} not found`);
148
+
149
+ const hasMutation =
150
+ params.subject !== undefined ||
151
+ params.description !== undefined ||
152
+ params.activeForm !== undefined ||
153
+ params.status !== undefined ||
154
+ params.blockedBy !== undefined ||
155
+ params.owner !== undefined ||
156
+ params.metadata !== undefined ||
157
+ (params.addBlockedBy !== undefined && params.addBlockedBy.length > 0) ||
158
+ (params.removeBlockedBy !== undefined &&
159
+ params.removeBlockedBy.length > 0);
160
+ if (!hasMutation) {
161
+ return errorResult(state, "update requires at least one mutable field");
162
+ }
163
+
164
+ let nextStatus = current.status;
165
+ if (params.status !== undefined) {
166
+ if (!isTransitionValid(current.status, params.status)) {
167
+ return errorResult(
168
+ state,
169
+ `illegal transition ${current.status} → ${params.status}`,
170
+ );
171
+ }
172
+ nextStatus = params.status;
173
+ }
174
+
175
+ let nextBlockedBy = [...(current.blockedBy ?? [])];
176
+ if (params.blockedBy !== undefined) {
177
+ nextBlockedBy = dedupeIds(params.blockedBy);
178
+ const blockerError = validateBlockers(
179
+ state,
180
+ current.id,
181
+ nextBlockedBy,
182
+ "blockedBy",
183
+ );
184
+ if (blockerError) return errorResult(state, blockerError);
185
+ }
186
+
187
+ if (params.removeBlockedBy?.length) {
188
+ const removalIds = dedupeIds(params.removeBlockedBy);
189
+ const removalError = validateRemovals(state, removalIds);
190
+ if (removalError) return errorResult(state, removalError);
191
+ const removals = new Set(removalIds);
192
+ nextBlockedBy = nextBlockedBy.filter(
193
+ (blockerId) => !removals.has(blockerId),
194
+ );
195
+ }
196
+
197
+ if (params.addBlockedBy?.length) {
198
+ const additions = dedupeIds(params.addBlockedBy);
199
+ const blockerError = validateBlockers(
200
+ state,
201
+ current.id,
202
+ additions,
203
+ "addBlockedBy",
204
+ );
205
+ if (blockerError) return errorResult(state, blockerError);
206
+ for (const blockerId of additions) {
207
+ if (!nextBlockedBy.includes(blockerId)) nextBlockedBy.push(blockerId);
208
+ }
209
+ }
210
+
211
+ const cycleCheckTasks = state.tasks.map((task) =>
212
+ task.id === current.id ? { ...task, blockedBy: [] } : task,
213
+ );
214
+ if (detectCycle(cycleCheckTasks, current.id, nextBlockedBy)) {
215
+ return errorResult(
216
+ state,
217
+ "addBlockedBy would create a cycle in the blockedBy graph",
218
+ );
219
+ }
220
+
221
+ const updated: Task = { ...current, status: nextStatus };
222
+ if (params.subject !== undefined) updated.subject = params.subject;
223
+ assignOptionalTaskFields(updated, params);
224
+ if (nextBlockedBy.length > 0) updated.blockedBy = nextBlockedBy;
225
+ else delete updated.blockedBy;
226
+
227
+ if (params.metadata !== undefined) {
228
+ const nextMetadata = mergeMetadata(current.metadata, params.metadata);
229
+ if (nextMetadata) updated.metadata = nextMetadata;
230
+ else delete updated.metadata;
231
+ }
232
+
233
+ const tasks = [...state.tasks];
234
+ tasks[index] = updated;
235
+
236
+ return {
237
+ state: { tasks, nextId: state.nextId },
238
+ op: {
239
+ kind: "update",
240
+ id: updated.id,
241
+ fromStatus: current.status,
242
+ toStatus: nextStatus,
243
+ },
244
+ };
245
+ }
246
+
247
+ case "list":
248
+ return {
249
+ state,
250
+ op: {
251
+ kind: "list",
252
+ includeDeleted: params.includeDeleted === true,
253
+ ...(params.status !== undefined
254
+ ? { statusFilter: params.status }
255
+ : {}),
256
+ },
257
+ };
258
+
259
+ case "get": {
260
+ if (!isValidId(params.id))
261
+ return errorResult(state, "id required for get");
262
+ const task = findTask(state.tasks, params.id);
263
+ if (!task) return errorResult(state, `#${params.id} not found`);
264
+ return { state, op: { kind: "get", task } };
265
+ }
266
+
267
+ case "delete": {
268
+ if (!isValidId(params.id))
269
+ return errorResult(state, "id required for delete");
270
+ const index = state.tasks.findIndex((task) => task.id === params.id);
271
+ if (index === -1) return errorResult(state, `#${params.id} not found`);
272
+ const current = state.tasks[index];
273
+ if (!current) return errorResult(state, `#${params.id} not found`);
274
+ if (current.status === "deleted")
275
+ return errorResult(state, `#${params.id} is already deleted`);
276
+
277
+ const updated: Task = { ...current, status: "deleted" };
278
+ const tasks = [...state.tasks];
279
+ tasks[index] = updated;
280
+ return {
281
+ state: { tasks, nextId: state.nextId },
282
+ op: { kind: "delete", id: updated.id, subject: updated.subject },
283
+ };
284
+ }
285
+
286
+ case "clear":
287
+ return {
288
+ state: { tasks: [], nextId: 1 },
289
+ op: { kind: "clear", count: state.tasks.length },
290
+ };
291
+ }
292
+ }
@@ -0,0 +1,69 @@
1
+ import type { Task } from "../tool/types.js";
2
+
3
+ /**
4
+ * Canonical in-session todo state. Reducers and replay produce this shape;
5
+ * store.ts is the only module-level live state cell.
6
+ */
7
+ export interface TaskState {
8
+ tasks: Task[];
9
+ nextId: number;
10
+ }
11
+
12
+ export const EMPTY_STATE: TaskState = { tasks: [], nextId: 1 };
13
+
14
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
16
+ const prototype = Object.getPrototypeOf(value);
17
+ return prototype === Object.prototype || prototype === null;
18
+ }
19
+
20
+ function cloneMetadataValue(value: unknown): unknown {
21
+ if (value === null || typeof value !== "object") return value;
22
+
23
+ if (typeof globalThis.structuredClone === "function") {
24
+ try {
25
+ return globalThis.structuredClone(value);
26
+ } catch {
27
+ // Fall through for values structuredClone cannot copy, like functions.
28
+ }
29
+ }
30
+
31
+ if (Array.isArray(value)) return value.map(cloneMetadataValue);
32
+
33
+ if (isPlainObject(value)) {
34
+ const cloned: Record<string, unknown> = {};
35
+ for (const [key, nestedValue] of Object.entries(value)) {
36
+ cloned[key] = cloneMetadataValue(nestedValue);
37
+ }
38
+ return cloned;
39
+ }
40
+
41
+ return value;
42
+ }
43
+
44
+ function cloneMetadata(
45
+ metadata: Record<string, unknown>,
46
+ ): Record<string, unknown> {
47
+ const cloned: Record<string, unknown> = {};
48
+ for (const [key, value] of Object.entries(metadata)) {
49
+ cloned[key] = cloneMetadataValue(value);
50
+ }
51
+ return cloned;
52
+ }
53
+
54
+ export function cloneTask(task: Task): Task {
55
+ const cloned: Task = { ...task };
56
+ if (task.blockedBy !== undefined) cloned.blockedBy = [...task.blockedBy];
57
+ else delete cloned.blockedBy;
58
+ if (task.metadata !== undefined)
59
+ cloned.metadata = cloneMetadata(task.metadata);
60
+ else delete cloned.metadata;
61
+ return cloned;
62
+ }
63
+
64
+ export function cloneTaskState(state: TaskState): TaskState {
65
+ return {
66
+ tasks: state.tasks.map(cloneTask),
67
+ nextId: state.nextId,
68
+ };
69
+ }
@@ -0,0 +1,37 @@
1
+ import type { Task } from "../tool/types.js";
2
+ import {
3
+ cloneTask,
4
+ cloneTaskState,
5
+ EMPTY_STATE,
6
+ type TaskState,
7
+ } from "./state.js";
8
+
9
+ let state: TaskState = cloneTaskState(EMPTY_STATE);
10
+
11
+ /** Task snapshot accessor for UI/command readers. */
12
+ export function getTodos(): readonly Task[] {
13
+ return state.tasks.map(cloneTask);
14
+ }
15
+
16
+ export function getNextId(): number {
17
+ return state.nextId;
18
+ }
19
+
20
+ /** State snapshot accessor for reducer callers. */
21
+ export function getState(): TaskState {
22
+ return cloneTaskState(state);
23
+ }
24
+
25
+ /** Replace live state from branch replay. */
26
+ export function replaceState(next: TaskState): void {
27
+ state = cloneTaskState(next);
28
+ }
29
+
30
+ /** Commit reducer output as live state. */
31
+ export function commitState(next: TaskState): void {
32
+ state = cloneTaskState(next);
33
+ }
34
+
35
+ export function __resetState(): void {
36
+ state = cloneTaskState(EMPTY_STATE);
37
+ }
@@ -0,0 +1,58 @@
1
+ import type { Task } from "../tool/types.js";
2
+
3
+ /**
4
+ * Checks whether merging `newBlockedBy` into `taskId`'s blockedBy set would
5
+ * introduce any cycle in the todo dependency graph.
6
+ */
7
+ export function detectCycle(
8
+ taskList: readonly Task[],
9
+ taskId: number,
10
+ newBlockedBy: readonly number[],
11
+ ): boolean {
12
+ const edges = new Map<number, number[]>();
13
+
14
+ for (const task of taskList) {
15
+ if (task.id === taskId) {
16
+ const merged = new Set([...(task.blockedBy ?? []), ...newBlockedBy]);
17
+ edges.set(task.id, [...merged]);
18
+ } else {
19
+ edges.set(task.id, [...(task.blockedBy ?? [])]);
20
+ }
21
+ }
22
+
23
+ const visiting = new Set<number>();
24
+ const visited = new Set<number>();
25
+
26
+ const hasCycleFrom = (node: number): boolean => {
27
+ if (visiting.has(node)) return true;
28
+ if (visited.has(node)) return false;
29
+
30
+ visiting.add(node);
31
+ for (const blocker of edges.get(node) ?? []) {
32
+ if (hasCycleFrom(blocker)) return true;
33
+ }
34
+ visiting.delete(node);
35
+ visited.add(node);
36
+ return false;
37
+ };
38
+
39
+ for (const node of edges.keys()) {
40
+ if (hasCycleFrom(node)) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Inverts blockedBy edges: blocker id -> task ids blocked by that blocker.
47
+ */
48
+ export function deriveBlocks(taskList: readonly Task[]): Map<number, number[]> {
49
+ const blocks = new Map<number, number[]>();
50
+ for (const task of taskList) {
51
+ for (const blockerId of task.blockedBy ?? []) {
52
+ const blockedTaskIds = blocks.get(blockerId) ?? [];
53
+ blockedTaskIds.push(task.id);
54
+ blocks.set(blockerId, blockedTaskIds);
55
+ }
56
+ }
57
+ return blocks;
58
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Persistent above-editor widget showing in-session todos.
3
+ *
4
+ * This class owns only widget lifecycle/rendering. Session lifecycle wiring lives
5
+ * outside this module so callers can decide when to bind UI, update, or hide
6
+ * completed tasks from previous turns.
7
+ */
8
+
9
+ import type {
10
+ ExtensionUIContext,
11
+ Theme,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { type TUI, truncateToWidth } from "@earendil-works/pi-tui";
14
+ import {
15
+ selectHasActive,
16
+ selectOverlayLayout,
17
+ selectShowTaskIds,
18
+ selectTodoCounts,
19
+ } from "./state/selectors.js";
20
+ import type { TaskState } from "./state/state.js";
21
+ import { getState } from "./state/store.js";
22
+ import { formatOverlayTaskLine, formatStatusLabel } from "./view/format.js";
23
+
24
+ const WIDGET_KEY = "rpiv-todos";
25
+ const MAX_WIDGET_LINES = 12;
26
+ const OVERLAY_HEADING = "Todos";
27
+ const OVERLAY_MORE = "more";
28
+
29
+ type TaskSnapshot = ReturnType<TodoOverlay["getSnapshot"]>;
30
+ type SnapshotTask = TaskSnapshot["tasks"][number];
31
+
32
+ export class TodoOverlay {
33
+ private uiCtx: ExtensionUIContext | undefined;
34
+ private widgetRegistered = false;
35
+ private tui: TUI | undefined;
36
+ private completedTaskIdsPendingHide = new Set<number>();
37
+ private hiddenCompletedTaskIds = new Set<number>();
38
+ private lastNextId: number | undefined;
39
+
40
+ setUICtx(ctx: ExtensionUIContext): void {
41
+ if (ctx === this.uiCtx) return;
42
+ this.uiCtx = ctx;
43
+ this.widgetRegistered = false;
44
+ this.tui = undefined;
45
+ }
46
+
47
+ update(): void {
48
+ if (!this.uiCtx) return;
49
+
50
+ const snapshot = this.getSnapshot();
51
+ const visibleTasks = this.selectOverlayTasks(snapshot);
52
+
53
+ if (visibleTasks.length === 0) {
54
+ if (this.widgetRegistered) {
55
+ this.uiCtx.setWidget(WIDGET_KEY, undefined);
56
+ this.widgetRegistered = false;
57
+ this.tui = undefined;
58
+ }
59
+ return;
60
+ }
61
+
62
+ if (!this.widgetRegistered) {
63
+ this.uiCtx.setWidget(
64
+ WIDGET_KEY,
65
+ (tui, theme) => {
66
+ this.tui = tui;
67
+ return {
68
+ render: (width: number) => this.renderWidget(theme, width),
69
+ invalidate: () => {
70
+ this.widgetRegistered = false;
71
+ this.tui = undefined;
72
+ },
73
+ };
74
+ },
75
+ { placement: "aboveEditor" },
76
+ );
77
+ this.widgetRegistered = true;
78
+ return;
79
+ }
80
+
81
+ this.tui?.requestRender();
82
+ }
83
+
84
+ resetCompletedDisplayState(): void {
85
+ this.completedTaskIdsPendingHide.clear();
86
+ this.hiddenCompletedTaskIds.clear();
87
+ this.lastNextId = undefined;
88
+ }
89
+
90
+ hideCompletedTasksFromPreviousTurn(): void {
91
+ if (this.completedTaskIdsPendingHide.size === 0) return;
92
+ for (const taskId of this.completedTaskIdsPendingHide) {
93
+ this.hiddenCompletedTaskIds.add(taskId);
94
+ }
95
+ this.completedTaskIdsPendingHide.clear();
96
+ this.update();
97
+ }
98
+
99
+ dispose(): void {
100
+ if (this.uiCtx) this.uiCtx.setWidget(WIDGET_KEY, undefined);
101
+ this.widgetRegistered = false;
102
+ this.tui = undefined;
103
+ this.uiCtx = undefined;
104
+ this.resetCompletedDisplayState();
105
+ }
106
+
107
+ private getSnapshot(): TaskState {
108
+ const state = getState();
109
+ if (this.lastNextId !== undefined && state.nextId < this.lastNextId) {
110
+ this.resetCompletedDisplayState();
111
+ }
112
+ this.lastNextId = state.nextId;
113
+
114
+ const completedTaskIds = new Set(
115
+ state.tasks
116
+ .filter((task) => task.status === "completed")
117
+ .map((task) => task.id),
118
+ );
119
+ for (const taskId of this.completedTaskIdsPendingHide) {
120
+ if (!completedTaskIds.has(taskId)) {
121
+ this.completedTaskIdsPendingHide.delete(taskId);
122
+ }
123
+ }
124
+ for (const taskId of this.hiddenCompletedTaskIds) {
125
+ if (!completedTaskIds.has(taskId)) {
126
+ this.hiddenCompletedTaskIds.delete(taskId);
127
+ }
128
+ }
129
+
130
+ return state;
131
+ }
132
+
133
+ private selectOverlayTasks(snapshot: TaskSnapshot): SnapshotTask[] {
134
+ return snapshot.tasks.filter(
135
+ (task) =>
136
+ task.status !== "deleted" && !this.shouldHideCompletedTask(task),
137
+ );
138
+ }
139
+
140
+ private shouldHideCompletedTask(task: SnapshotTask): boolean {
141
+ return (
142
+ task.status === "completed" && this.hiddenCompletedTaskIds.has(task.id)
143
+ );
144
+ }
145
+
146
+ private renderWidget(theme: Theme, width: number): string[] {
147
+ const snapshot = this.getSnapshot();
148
+ const overlayTasks = this.selectOverlayTasks(snapshot);
149
+ if (overlayTasks.length === 0) return [];
150
+
151
+ const overlayState: TaskState = {
152
+ tasks: overlayTasks,
153
+ nextId: snapshot.nextId,
154
+ };
155
+ const truncate = (line: string): string =>
156
+ truncateToWidth(line, width, "…");
157
+ const counts = selectTodoCounts(overlayState);
158
+ const hasActive = selectHasActive(overlayState);
159
+ const showIds = selectShowTaskIds(overlayState);
160
+ const headingColor = hasActive ? "accent" : "dim";
161
+ const headingIcon = hasActive ? "●" : "○";
162
+ const headingText = `${OVERLAY_HEADING} (${counts.completed}/${counts.total})`;
163
+ const heading = truncate(
164
+ `${theme.fg(headingColor, headingIcon)} ${theme.fg(
165
+ headingColor,
166
+ headingText,
167
+ )}`,
168
+ );
169
+ const lines = [heading];
170
+ const layout = selectOverlayLayout(overlayState, MAX_WIDGET_LINES - 1);
171
+
172
+ for (const task of layout.visible) {
173
+ lines.push(
174
+ truncate(
175
+ `${theme.fg("dim", "├─")} ${formatOverlayTaskLine(
176
+ task,
177
+ theme,
178
+ showIds,
179
+ )}`,
180
+ ),
181
+ );
182
+ }
183
+
184
+ const newlyDisplayedCompletedTaskIds = overlayTasks
185
+ .filter(
186
+ (task) =>
187
+ task.status === "completed" &&
188
+ !this.completedTaskIdsPendingHide.has(task.id) &&
189
+ !this.hiddenCompletedTaskIds.has(task.id),
190
+ )
191
+ .map((task) => task.id);
192
+ for (const taskId of newlyDisplayedCompletedTaskIds) {
193
+ this.completedTaskIdsPendingHide.add(taskId);
194
+ }
195
+
196
+ if (layout.hiddenCompleted === 0 && layout.truncatedTail === 0) {
197
+ const lastIndex = lines.length - 1;
198
+ lines[lastIndex] = lines[lastIndex]!.replace("├─", "└─");
199
+ return lines;
200
+ }
201
+
202
+ const totalHidden = layout.hiddenCompleted + layout.truncatedTail;
203
+ const overflowParts: string[] = [];
204
+ if (layout.hiddenCompleted > 0) {
205
+ overflowParts.push(
206
+ `${layout.hiddenCompleted} ${formatStatusLabel("completed")}`,
207
+ );
208
+ }
209
+ if (layout.truncatedTail > 0) {
210
+ overflowParts.push(
211
+ `${layout.truncatedTail} ${formatStatusLabel("pending")}`,
212
+ );
213
+ }
214
+ const summary =
215
+ overflowParts.length > 0
216
+ ? `+${totalHidden} ${OVERLAY_MORE} (${overflowParts.join(", ")})`
217
+ : `+${totalHidden} ${OVERLAY_MORE}`;
218
+ lines.push(
219
+ truncate(`${theme.fg("dim", "└─")} ${theme.fg("dim", summary)}`),
220
+ );
221
+ return lines;
222
+ }
223
+ }