@trevonistrevon/pi-loop 0.3.0 → 0.4.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.
- package/DIFFERENTIAL_REVIEW_REPORT.md +59 -0
- package/README.md +50 -6
- package/dist/index.js +301 -62
- package/dist/scheduler.js +3 -3
- package/dist/task-store.d.ts +22 -0
- package/dist/task-store.js +181 -0
- package/dist/task-types.d.ts +15 -0
- package/dist/task-types.js +1 -0
- package/dist/trigger-system.d.ts +2 -1
- package/dist/trigger-system.js +14 -16
- package/dist/types.d.ts +1 -1
- package/dist/ui/widget.d.ts +8 -4
- package/dist/ui/widget.js +28 -74
- package/package.json +1 -1
- package/src/index.ts +300 -57
- package/src/scheduler.ts +3 -3
- package/src/task-store.ts +171 -0
- package/src/task-types.ts +17 -0
- package/src/trigger-system.ts +14 -16
- package/src/types.ts +1 -1
- package/src/ui/widget.ts +27 -70
package/dist/scheduler.js
CHANGED
|
@@ -55,7 +55,7 @@ export class CronScheduler {
|
|
|
55
55
|
const fireTime = nextFire.getTime() + jitter;
|
|
56
56
|
const now = Date.now();
|
|
57
57
|
if (fireTime > entry.expiresAt) {
|
|
58
|
-
this.store.
|
|
58
|
+
this.store.delete(entry.id);
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
const delay = Math.max(0, fireTime - now);
|
|
@@ -72,7 +72,7 @@ export class CronScheduler {
|
|
|
72
72
|
}
|
|
73
73
|
const now2 = Date.now();
|
|
74
74
|
if (now2 >= current.expiresAt) {
|
|
75
|
-
this.store.
|
|
75
|
+
this.store.delete(entry.id);
|
|
76
76
|
this.timers.delete(entry.id);
|
|
77
77
|
this.fireTimes.delete(entry.id);
|
|
78
78
|
return;
|
|
@@ -81,7 +81,7 @@ export class CronScheduler {
|
|
|
81
81
|
if (current.recurring) {
|
|
82
82
|
const fresh = this.store.get(entry.id);
|
|
83
83
|
if (fresh?.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
|
|
84
|
-
this.store.
|
|
84
|
+
this.store.delete(entry.id);
|
|
85
85
|
this.timers.delete(entry.id);
|
|
86
86
|
this.fireTimes.delete(entry.id);
|
|
87
87
|
return;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TaskEntry, TaskStatus } from "./task-types.js";
|
|
2
|
+
export declare class TaskStore {
|
|
3
|
+
private filePath;
|
|
4
|
+
private lockPath;
|
|
5
|
+
private nextId;
|
|
6
|
+
private tasks;
|
|
7
|
+
constructor(listIdOrPath?: string);
|
|
8
|
+
private load;
|
|
9
|
+
private save;
|
|
10
|
+
private withLock;
|
|
11
|
+
create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry;
|
|
12
|
+
get(id: string): TaskEntry | undefined;
|
|
13
|
+
list(): TaskEntry[];
|
|
14
|
+
update(id: string, fields: {
|
|
15
|
+
status?: TaskStatus;
|
|
16
|
+
subject?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}): TaskEntry | undefined;
|
|
19
|
+
delete(id: string): boolean;
|
|
20
|
+
pendingCount(): number;
|
|
21
|
+
sweepCompleted(): number;
|
|
22
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
|
5
|
+
const LOCK_RETRY_MS = 50;
|
|
6
|
+
const LOCK_MAX_RETRIES = 100;
|
|
7
|
+
const MAX_TASKS = 200;
|
|
8
|
+
function acquireLock(lockPath) {
|
|
9
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
if (e.code === "EEXIST") {
|
|
16
|
+
try {
|
|
17
|
+
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
18
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
19
|
+
try {
|
|
20
|
+
unlinkSync(lockPath);
|
|
21
|
+
}
|
|
22
|
+
catch { /* ignore */ }
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch { /* ignore read errors */ }
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
35
|
+
}
|
|
36
|
+
function releaseLock(lockPath) {
|
|
37
|
+
try {
|
|
38
|
+
unlinkSync(lockPath);
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
function isProcessRunning(pid) {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class TaskStore {
|
|
52
|
+
filePath;
|
|
53
|
+
lockPath;
|
|
54
|
+
nextId = 1;
|
|
55
|
+
tasks = new Map();
|
|
56
|
+
constructor(listIdOrPath) {
|
|
57
|
+
if (!listIdOrPath)
|
|
58
|
+
return;
|
|
59
|
+
const isAbsPath = isAbsolute(listIdOrPath);
|
|
60
|
+
const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
|
|
61
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
62
|
+
this.filePath = filePath;
|
|
63
|
+
this.lockPath = filePath + ".lock";
|
|
64
|
+
this.load();
|
|
65
|
+
}
|
|
66
|
+
load() {
|
|
67
|
+
if (!this.filePath)
|
|
68
|
+
return;
|
|
69
|
+
if (!existsSync(this.filePath))
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
73
|
+
this.nextId = data.nextId;
|
|
74
|
+
this.tasks.clear();
|
|
75
|
+
for (const task of data.tasks) {
|
|
76
|
+
this.tasks.set(task.id, task);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch { /* corrupt file — start fresh */ }
|
|
80
|
+
}
|
|
81
|
+
save() {
|
|
82
|
+
if (!this.filePath)
|
|
83
|
+
return;
|
|
84
|
+
const data = {
|
|
85
|
+
nextId: this.nextId,
|
|
86
|
+
tasks: Array.from(this.tasks.values()),
|
|
87
|
+
};
|
|
88
|
+
const tmpPath = this.filePath + ".tmp";
|
|
89
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
90
|
+
renameSync(tmpPath, this.filePath);
|
|
91
|
+
}
|
|
92
|
+
withLock(fn) {
|
|
93
|
+
if (!this.lockPath)
|
|
94
|
+
return fn();
|
|
95
|
+
acquireLock(this.lockPath);
|
|
96
|
+
try {
|
|
97
|
+
this.load();
|
|
98
|
+
const result = fn();
|
|
99
|
+
this.save();
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
releaseLock(this.lockPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
create(subject, description, metadata) {
|
|
107
|
+
return this.withLock(() => {
|
|
108
|
+
if (this.tasks.size >= MAX_TASKS) {
|
|
109
|
+
throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
|
|
110
|
+
}
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const entry = {
|
|
113
|
+
id: String(this.nextId++),
|
|
114
|
+
subject,
|
|
115
|
+
description,
|
|
116
|
+
status: "pending",
|
|
117
|
+
createdAt: now,
|
|
118
|
+
updatedAt: now,
|
|
119
|
+
metadata,
|
|
120
|
+
};
|
|
121
|
+
this.tasks.set(entry.id, entry);
|
|
122
|
+
return entry;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
get(id) {
|
|
126
|
+
if (this.filePath)
|
|
127
|
+
this.load();
|
|
128
|
+
return this.tasks.get(id);
|
|
129
|
+
}
|
|
130
|
+
list() {
|
|
131
|
+
if (this.filePath)
|
|
132
|
+
this.load();
|
|
133
|
+
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
134
|
+
}
|
|
135
|
+
update(id, fields) {
|
|
136
|
+
return this.withLock(() => {
|
|
137
|
+
const entry = this.tasks.get(id);
|
|
138
|
+
if (!entry)
|
|
139
|
+
return undefined;
|
|
140
|
+
if (fields.status !== undefined) {
|
|
141
|
+
entry.status = fields.status;
|
|
142
|
+
if (fields.status === "completed")
|
|
143
|
+
entry.completedAt = Date.now();
|
|
144
|
+
}
|
|
145
|
+
if (fields.subject !== undefined)
|
|
146
|
+
entry.subject = fields.subject;
|
|
147
|
+
if (fields.description !== undefined)
|
|
148
|
+
entry.description = fields.description;
|
|
149
|
+
entry.updatedAt = Date.now();
|
|
150
|
+
return entry;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
delete(id) {
|
|
154
|
+
return this.withLock(() => {
|
|
155
|
+
if (!this.tasks.has(id))
|
|
156
|
+
return false;
|
|
157
|
+
this.tasks.delete(id);
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
pendingCount() {
|
|
162
|
+
let count = 0;
|
|
163
|
+
for (const t of this.tasks.values()) {
|
|
164
|
+
if (t.status === "pending" || t.status === "in_progress")
|
|
165
|
+
count++;
|
|
166
|
+
}
|
|
167
|
+
return count;
|
|
168
|
+
}
|
|
169
|
+
sweepCompleted() {
|
|
170
|
+
return this.withLock(() => {
|
|
171
|
+
let count = 0;
|
|
172
|
+
for (const [id, entry] of this.tasks) {
|
|
173
|
+
if (entry.status === "completed") {
|
|
174
|
+
this.tasks.delete(id);
|
|
175
|
+
count++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return count;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TaskStatus = "pending" | "in_progress" | "completed";
|
|
2
|
+
export interface TaskEntry {
|
|
3
|
+
id: string;
|
|
4
|
+
subject: string;
|
|
5
|
+
description: string;
|
|
6
|
+
status: TaskStatus;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
updatedAt: number;
|
|
9
|
+
completedAt?: number;
|
|
10
|
+
metadata?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface TaskStoreData {
|
|
13
|
+
nextId: number;
|
|
14
|
+
tasks: TaskEntry[];
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/trigger-system.d.ts
CHANGED
|
@@ -6,10 +6,11 @@ export declare class TriggerSystem {
|
|
|
6
6
|
private pi;
|
|
7
7
|
private scheduler;
|
|
8
8
|
private store;
|
|
9
|
+
private onFire;
|
|
9
10
|
private eventSubscriptions;
|
|
10
11
|
private hybridTimers;
|
|
11
12
|
private lastFireTime;
|
|
12
|
-
constructor(pi: ExtensionAPI, scheduler: CronScheduler, store: LoopStore);
|
|
13
|
+
constructor(pi: ExtensionAPI, scheduler: CronScheduler, store: LoopStore, onFire: (entry: LoopEntry) => void);
|
|
13
14
|
start(): void;
|
|
14
15
|
stop(): void;
|
|
15
16
|
add(entry: LoopEntry): void;
|
package/dist/trigger-system.js
CHANGED
|
@@ -2,13 +2,15 @@ export class TriggerSystem {
|
|
|
2
2
|
pi;
|
|
3
3
|
scheduler;
|
|
4
4
|
store;
|
|
5
|
+
onFire;
|
|
5
6
|
eventSubscriptions = new Map();
|
|
6
7
|
hybridTimers = new Map();
|
|
7
8
|
lastFireTime = new Map();
|
|
8
|
-
constructor(pi, scheduler, store) {
|
|
9
|
+
constructor(pi, scheduler, store, onFire) {
|
|
9
10
|
this.pi = pi;
|
|
10
11
|
this.scheduler = scheduler;
|
|
11
12
|
this.store = store;
|
|
13
|
+
this.onFire = onFire;
|
|
12
14
|
}
|
|
13
15
|
start() {
|
|
14
16
|
this.scheduler.start();
|
|
@@ -89,25 +91,21 @@ export class TriggerSystem {
|
|
|
89
91
|
this.hybridTimers.set(entry.id, timer);
|
|
90
92
|
}
|
|
91
93
|
fireLoop(entry) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
const current = this.store.get(entry.id);
|
|
95
|
+
if (!current || current.status !== "active") {
|
|
94
96
|
this.remove(entry.id);
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
|
-
this.
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
-
|
|
101
|
-
prompt: entry.prompt,
|
|
102
|
-
trigger: entry.trigger,
|
|
103
|
-
timestamp: Date.now(),
|
|
104
|
-
readOnly: entry.readOnly,
|
|
105
|
-
recurring: entry.recurring,
|
|
106
|
-
autoTask: entry.autoTask,
|
|
107
|
-
});
|
|
108
|
-
if (!entry.recurring) {
|
|
99
|
+
this.lastFireTime.set(current.id, Date.now());
|
|
100
|
+
this.onFire(current);
|
|
101
|
+
const fresh = this.store.get(entry.id);
|
|
102
|
+
if (!fresh) {
|
|
109
103
|
this.remove(entry.id);
|
|
110
|
-
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!fresh.recurring) {
|
|
107
|
+
this.remove(fresh.id);
|
|
108
|
+
this.store.delete(fresh.id);
|
|
111
109
|
}
|
|
112
110
|
}
|
|
113
111
|
matchesFilter(data, filter) {
|
package/dist/types.d.ts
CHANGED
package/dist/ui/widget.d.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { MonitorManager } from "../monitor-manager.js";
|
|
3
|
-
import type { CronScheduler } from "../scheduler.js";
|
|
4
3
|
import type { LoopStore } from "../store.js";
|
|
4
|
+
interface TaskSummary {
|
|
5
|
+
count: number;
|
|
6
|
+
focusText?: string;
|
|
7
|
+
}
|
|
5
8
|
export declare class LoopWidget {
|
|
6
9
|
private store;
|
|
7
|
-
private scheduler;
|
|
8
10
|
private monitorManager;
|
|
9
11
|
private uiCtx;
|
|
10
12
|
private tui;
|
|
11
13
|
private widgetRegistered;
|
|
12
14
|
private interval;
|
|
13
|
-
|
|
15
|
+
private taskSummaryProvider;
|
|
16
|
+
constructor(store: LoopStore, monitorManager: MonitorManager);
|
|
14
17
|
setUICtx(ctx: ExtensionUIContext): void;
|
|
15
18
|
setStore(store: LoopStore): void;
|
|
16
|
-
|
|
19
|
+
setTaskSummaryProvider(provider: (() => TaskSummary) | undefined): void;
|
|
17
20
|
update(): void;
|
|
18
21
|
private renderWidget;
|
|
19
22
|
dispose(): void;
|
|
20
23
|
}
|
|
24
|
+
export {};
|
package/dist/ui/widget.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
const MAX_VISIBLE = 6;
|
|
3
2
|
export class LoopWidget {
|
|
4
3
|
store;
|
|
5
|
-
scheduler;
|
|
6
4
|
monitorManager;
|
|
7
5
|
uiCtx;
|
|
8
6
|
tui;
|
|
9
7
|
widgetRegistered = false;
|
|
10
8
|
interval;
|
|
11
|
-
|
|
9
|
+
taskSummaryProvider;
|
|
10
|
+
constructor(store, monitorManager) {
|
|
12
11
|
this.store = store;
|
|
13
|
-
this.scheduler = scheduler;
|
|
14
12
|
this.monitorManager = monitorManager;
|
|
15
13
|
}
|
|
16
14
|
setUICtx(ctx) {
|
|
@@ -19,29 +17,21 @@ export class LoopWidget {
|
|
|
19
17
|
setStore(store) {
|
|
20
18
|
this.store = store;
|
|
21
19
|
}
|
|
22
|
-
|
|
23
|
-
this.
|
|
20
|
+
setTaskSummaryProvider(provider) {
|
|
21
|
+
this.taskSummaryProvider = provider;
|
|
24
22
|
}
|
|
25
23
|
update() {
|
|
26
24
|
if (!this.uiCtx)
|
|
27
25
|
return;
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
if (!hasContent) {
|
|
32
|
-
if (this.widgetRegistered) {
|
|
33
|
-
this.uiCtx.setWidget("loops", undefined);
|
|
34
|
-
this.widgetRegistered = false;
|
|
35
|
-
}
|
|
36
|
-
if (this.interval) {
|
|
37
|
-
clearInterval(this.interval);
|
|
38
|
-
this.interval = undefined;
|
|
39
|
-
}
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (!this.interval) {
|
|
26
|
+
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
27
|
+
const hasContent = this.store.list().some(l => l.status === "active") || this.monitorManager.list().length > 0 || taskSummary.count > 0;
|
|
28
|
+
if (hasContent && !this.interval) {
|
|
43
29
|
this.interval = setInterval(() => this.update(), 5000);
|
|
44
30
|
}
|
|
31
|
+
if (!hasContent && this.interval) {
|
|
32
|
+
clearInterval(this.interval);
|
|
33
|
+
this.interval = undefined;
|
|
34
|
+
}
|
|
45
35
|
if (!this.widgetRegistered) {
|
|
46
36
|
this.uiCtx.setWidget("loops", (tui, theme) => {
|
|
47
37
|
this.tui = tui;
|
|
@@ -55,51 +45,24 @@ export class LoopWidget {
|
|
|
55
45
|
}
|
|
56
46
|
renderWidget(tui, _theme) {
|
|
57
47
|
const loops = this.store.list().filter(l => l.status === "active");
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const allMonitors = [...runningMonitors, ...completedMonitors];
|
|
48
|
+
const monitors = this.monitorManager.list();
|
|
49
|
+
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
61
50
|
const w = tui.terminal.columns;
|
|
62
51
|
const trunc = (line) => truncateToWidth(line, w);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (total === 0)
|
|
66
|
-
return [];
|
|
67
|
-
const headerParts = [`⟳ ${loops.length} loops`];
|
|
68
|
-
if (runningMonitors.length > 0)
|
|
69
|
-
headerParts.push(`${runningMonitors.length} running`);
|
|
70
|
-
if (completedMonitors.length > 0)
|
|
71
|
-
headerParts.push(`${completedMonitors.length} done`);
|
|
72
|
-
lines.push(trunc(headerParts.join(" · ")));
|
|
73
|
-
for (const loop of loops.slice(0, MAX_VISIBLE)) {
|
|
74
|
-
const icon = "◷";
|
|
75
|
-
let schedule = "";
|
|
76
|
-
if (loop.trigger.type === "cron") {
|
|
77
|
-
schedule = loop.trigger.schedule;
|
|
78
|
-
}
|
|
79
|
-
else if (loop.trigger.type === "event") {
|
|
80
|
-
schedule = `event: ${loop.trigger.source}`;
|
|
81
|
-
}
|
|
82
|
-
else if (loop.trigger.type === "hybrid") {
|
|
83
|
-
schedule = `hybrid: ${loop.trigger.cron}`;
|
|
84
|
-
}
|
|
85
|
-
const nextFire = this.scheduler?.nextFire(loop.id);
|
|
86
|
-
let timing = "";
|
|
87
|
-
if (nextFire) {
|
|
88
|
-
const remaining = Math.max(0, nextFire - Date.now());
|
|
89
|
-
timing = ` (next: ${formatDuration(remaining)})`;
|
|
90
|
-
}
|
|
91
|
-
lines.push(trunc(` ${icon} #${loop.id} ${loop.prompt.slice(0, 50)} → ${schedule}${timing}`));
|
|
92
|
-
}
|
|
93
|
-
for (const m of allMonitors.slice(0, Math.max(0, MAX_VISIBLE - loops.length))) {
|
|
94
|
-
const icon = m.status === "running" ? "◉" : "✓";
|
|
95
|
-
const age = Date.now() - m.startedAt;
|
|
96
|
-
const label = m.description || m.command.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 50);
|
|
97
|
-
let line = ` ${icon} #${m.id} ${label} ${m.outputLines} lines (${formatDuration(age)})`;
|
|
98
|
-
if (m.exitCode !== undefined && m.status !== "running")
|
|
99
|
-
line += ` exit=${m.exitCode}`;
|
|
100
|
-
lines.push(trunc(line));
|
|
52
|
+
if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
|
|
53
|
+
return [trunc("none")];
|
|
101
54
|
}
|
|
102
|
-
|
|
55
|
+
const parts = [];
|
|
56
|
+
if (loops.length > 0)
|
|
57
|
+
parts.push(formatCount(loops.length, "loop"));
|
|
58
|
+
if (monitors.length > 0)
|
|
59
|
+
parts.push(formatCount(monitors.length, "monitor"));
|
|
60
|
+
if (taskSummary.count > 0)
|
|
61
|
+
parts.push(formatCount(taskSummary.count, "task"));
|
|
62
|
+
let line = parts.join(" · ");
|
|
63
|
+
if (taskSummary.focusText)
|
|
64
|
+
line += ` | ${taskSummary.focusText}`;
|
|
65
|
+
return [trunc(line)];
|
|
103
66
|
}
|
|
104
67
|
dispose() {
|
|
105
68
|
if (this.interval) {
|
|
@@ -112,15 +75,6 @@ export class LoopWidget {
|
|
|
112
75
|
this.tui = undefined;
|
|
113
76
|
}
|
|
114
77
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
if (totalSec < 60)
|
|
118
|
-
return `${totalSec}s`;
|
|
119
|
-
const min = Math.floor(totalSec / 60);
|
|
120
|
-
const sec = totalSec % 60;
|
|
121
|
-
if (min < 60)
|
|
122
|
-
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
|
123
|
-
const hr = Math.floor(min / 60);
|
|
124
|
-
const remMin = min % 60;
|
|
125
|
-
return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
|
|
78
|
+
function formatCount(count, noun) {
|
|
79
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
126
80
|
}
|
package/package.json
CHANGED