@xvzc/pi-tasks 1.0.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/README.md +138 -0
- package/package.json +40 -0
- package/src/index.ts +353 -0
- package/src/store.ts +763 -0
- package/src/tasks-ui.ts +228 -0
- package/src/types.ts +57 -0
- package/src/widget.ts +341 -0
package/src/tasks-ui.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small terminal overlay for `/tasks view`: left task list, right details.
|
|
3
|
+
* Pure helpers plus a keyboard-driven component using public pi-tui APIs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { KeybindingsManager } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { Task } from "./types.js";
|
|
9
|
+
import type { ThemeLike } from "./widget.js";
|
|
10
|
+
import { blockedBySuffix, statusGlyph } from "./widget.js";
|
|
11
|
+
|
|
12
|
+
/** Visible detail rows in the right pane; PageUp/PageDown move by this amount. */
|
|
13
|
+
export const TASK_VIEWER_PAGE_SIZE = 10;
|
|
14
|
+
|
|
15
|
+
/** Single-line label for the left task list. */
|
|
16
|
+
export function taskRowLabel(task: Task): string {
|
|
17
|
+
return `${statusGlyph(task)} #${task.id} (${task.attempt}/${task.maxAttempts}) ${task.subject}${blockedBySuffix(task)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Full detail lines for the right pane (plain text; the component truncates). */
|
|
21
|
+
export function buildTaskDetailLines(task: Task): string[] {
|
|
22
|
+
const lines: string[] = [
|
|
23
|
+
`Status: ${task.status}`,
|
|
24
|
+
`Task: #${task.id} (${task.attempt}/${task.maxAttempts})`,
|
|
25
|
+
`Assignee: ${task.assignee ?? "(none)"}`,
|
|
26
|
+
`Subject: ${task.subject}`,
|
|
27
|
+
...task.description.split("\n").map((line, index) => `${index === 0 ? "Description" : " "}: ${line}`),
|
|
28
|
+
`Blocked by: ${task.blockedBy.length === 0 ? "(none)" : task.blockedBy.map((id) => `#${id}`).join(", ")}`,
|
|
29
|
+
`Created: ${task.createdAt}`,
|
|
30
|
+
`Updated: ${task.updatedAt}`,
|
|
31
|
+
];
|
|
32
|
+
if (task.startedAt !== undefined) lines.push(`Started: ${task.startedAt}`);
|
|
33
|
+
if (task.tookMs !== undefined) lines.push(`Took: ${task.tookMs}ms`);
|
|
34
|
+
lines.push(`Metadata: ${JSON.stringify(task.metadata)}`);
|
|
35
|
+
lines.push(`Log (${task.log.length}):`);
|
|
36
|
+
for (const entry of task.log) lines.push(` [${entry.timestamp}] ${entry.message}`);
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fit(line: string, width: number): string {
|
|
41
|
+
if (!Number.isFinite(width) || width <= 0) return "";
|
|
42
|
+
if (visibleWidth(line) <= width) return line;
|
|
43
|
+
return truncateToWidth(line, width);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isKey(data: string, id: Parameters<typeof matchesKey>[1]): boolean {
|
|
47
|
+
try {
|
|
48
|
+
return matchesKey(data, id);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function matchesBinding(
|
|
55
|
+
keybindings: TasksViewerOptions["keybindings"],
|
|
56
|
+
data: string,
|
|
57
|
+
binding: "tui.editor.cursorUp" | "tui.editor.cursorDown",
|
|
58
|
+
): boolean {
|
|
59
|
+
if (!keybindings) return false;
|
|
60
|
+
try {
|
|
61
|
+
return keybindings.matches(data, binding);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function addBorder(lines: string[], width: number, theme?: ThemeLike): string[] {
|
|
68
|
+
const paint = (text: string): string => {
|
|
69
|
+
if (!theme) return text;
|
|
70
|
+
try {
|
|
71
|
+
return theme.fg("border", text);
|
|
72
|
+
} catch {
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
if (width === 1) return [paint("╷"), ...lines.map(() => paint("│")), paint("╵")];
|
|
77
|
+
const innerWidth = width - 2;
|
|
78
|
+
const horizontal = "─".repeat(innerWidth);
|
|
79
|
+
return [
|
|
80
|
+
paint(`┌${horizontal}┐`),
|
|
81
|
+
...lines.map((line) => {
|
|
82
|
+
const content = fit(line, innerWidth);
|
|
83
|
+
return `${paint("│")}${content}${" ".repeat(Math.max(0, innerWidth - visibleWidth(content)))}${paint("│")}`;
|
|
84
|
+
}),
|
|
85
|
+
paint(`└${horizontal}┘`),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface TasksViewerOptions {
|
|
90
|
+
/** Called to close the overlay. */
|
|
91
|
+
done: () => void;
|
|
92
|
+
theme?: ThemeLike;
|
|
93
|
+
/** Request a redraw after navigation (the TUI object from the custom factory). */
|
|
94
|
+
tui?: { requestRender?: ((force?: boolean) => void) | undefined; renderNow?: ((force?: boolean) => void) | undefined };
|
|
95
|
+
/** Resolves the configured `tui.editor.cursorUp` / `tui.editor.cursorDown` bindings. */
|
|
96
|
+
keybindings?: Pick<KeybindingsManager, "matches">;
|
|
97
|
+
/** Visible detail rows; defaults to TASK_VIEWER_PAGE_SIZE. */
|
|
98
|
+
pageSize?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type TasksViewer = {
|
|
102
|
+
render(width: number): string[];
|
|
103
|
+
handleInput(data: string): void;
|
|
104
|
+
invalidate(): void;
|
|
105
|
+
dispose(): void;
|
|
106
|
+
getSelected(): number;
|
|
107
|
+
getDetailOffset(): number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Keyboard-driven two-column viewer. Selection follows the configured
|
|
112
|
+
* `tui.editor.cursorUp` / `tui.editor.cursorDown` bindings (detail scroll
|
|
113
|
+
* resets); PageUp/PageDown scrolls the detail pane; Escape or Ctrl+C closes.
|
|
114
|
+
*/
|
|
115
|
+
export function createTasksViewer(tasks: Task[], options: TasksViewerOptions): TasksViewer {
|
|
116
|
+
const snapshot = [...tasks].sort((a, b) => a.id - b.id);
|
|
117
|
+
const pageSize =
|
|
118
|
+
typeof options.pageSize === "number" && Number.isFinite(options.pageSize) && options.pageSize > 0
|
|
119
|
+
? Math.floor(options.pageSize)
|
|
120
|
+
: TASK_VIEWER_PAGE_SIZE;
|
|
121
|
+
let selected = 0;
|
|
122
|
+
let detailOffset = 0;
|
|
123
|
+
|
|
124
|
+
const detailLines = (): string[] =>
|
|
125
|
+
snapshot.length === 0 ? [] : buildTaskDetailLines(snapshot[Math.min(selected, snapshot.length - 1)]);
|
|
126
|
+
|
|
127
|
+
function maxOffset(): number {
|
|
128
|
+
return Math.max(0, detailLines().length - pageSize);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function redraw(): void {
|
|
132
|
+
try {
|
|
133
|
+
if (typeof options.tui?.requestRender === "function") options.tui.requestRender();
|
|
134
|
+
else if (typeof options.tui?.renderNow === "function") options.tui.renderNow();
|
|
135
|
+
} catch {
|
|
136
|
+
// Redraws are best-effort in tests and teardown.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function header(): string {
|
|
141
|
+
const plain = `Tasks (${snapshot.length})`;
|
|
142
|
+
if (!options.theme) return plain;
|
|
143
|
+
try {
|
|
144
|
+
return options.theme.fg("accent", plain);
|
|
145
|
+
} catch {
|
|
146
|
+
return plain;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const HINT = "Up/Down select · PgUp/PgDn scroll · Esc close";
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
render(width: number) {
|
|
154
|
+
if (!Number.isFinite(width) || width <= 0) return [];
|
|
155
|
+
const innerWidth = Math.max(0, width - 2);
|
|
156
|
+
const lines: string[] = [];
|
|
157
|
+
if (snapshot.length === 0) {
|
|
158
|
+
return addBorder([fit(header(), innerWidth), fit("No tasks.", innerWidth), fit(HINT, innerWidth)], width, options.theme);
|
|
159
|
+
}
|
|
160
|
+
const selectedTask = snapshot[Math.min(selected, snapshot.length - 1)];
|
|
161
|
+
const allDetail = buildTaskDetailLines(selectedTask);
|
|
162
|
+
const offset = Math.min(detailOffset, Math.max(0, allDetail.length - pageSize));
|
|
163
|
+
const visibleDetail = allDetail.slice(offset, offset + pageSize);
|
|
164
|
+
|
|
165
|
+
// Narrow terminals stack list above details instead of squeezing columns.
|
|
166
|
+
if (innerWidth < 40) {
|
|
167
|
+
lines.push(fit(header(), innerWidth));
|
|
168
|
+
snapshot.forEach((task, index) => {
|
|
169
|
+
lines.push(fit(`${index === selected ? "> " : " "}${taskRowLabel(task)}`, innerWidth));
|
|
170
|
+
});
|
|
171
|
+
lines.push(fit("—", innerWidth));
|
|
172
|
+
for (const line of visibleDetail) lines.push(fit(line, innerWidth));
|
|
173
|
+
lines.push(fit(HINT, innerWidth));
|
|
174
|
+
return addBorder(lines, width, options.theme);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const leftW = Math.max(18, Math.min(32, Math.floor(innerWidth * 0.35)));
|
|
178
|
+
const rightW = innerWidth - leftW - 3;
|
|
179
|
+
lines.push(fit(header(), innerWidth));
|
|
180
|
+
const rowCount = Math.max(snapshot.length, visibleDetail.length);
|
|
181
|
+
for (let i = 0; i < rowCount; i++) {
|
|
182
|
+
const leftRaw = i < snapshot.length ? `${i === selected ? "> " : " "}${taskRowLabel(snapshot[i])}` : "";
|
|
183
|
+
const leftFit = fit(leftRaw, leftW);
|
|
184
|
+
const leftPadded = leftFit + " ".repeat(Math.max(0, leftW - visibleWidth(leftFit)));
|
|
185
|
+
const rightFit = rightW > 0 ? fit(visibleDetail[i] ?? "", rightW) : "";
|
|
186
|
+
lines.push(rightW > 0 ? `${leftPadded} │ ${rightFit}` : leftPadded);
|
|
187
|
+
}
|
|
188
|
+
lines.push(fit(HINT, innerWidth));
|
|
189
|
+
return addBorder(lines, width, options.theme);
|
|
190
|
+
},
|
|
191
|
+
handleInput(data: string) {
|
|
192
|
+
if (data === "\x1b" || data === "\x03" || isKey(data, "escape") || isKey(data, "ctrl+c")) {
|
|
193
|
+
options.done();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (snapshot.length === 0) return;
|
|
197
|
+
if (data === "\x1b[A" || isKey(data, "up") || matchesBinding(options.keybindings, data, "tui.editor.cursorUp")) {
|
|
198
|
+
selected = (selected - 1 + snapshot.length) % snapshot.length;
|
|
199
|
+
detailOffset = 0;
|
|
200
|
+
redraw();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (data === "\x1b[B" || isKey(data, "down") || matchesBinding(options.keybindings, data, "tui.editor.cursorDown")) {
|
|
204
|
+
selected = (selected + 1) % snapshot.length;
|
|
205
|
+
detailOffset = 0;
|
|
206
|
+
redraw();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (data === "\x1b[5~" || isKey(data, "pageUp")) {
|
|
210
|
+
detailOffset = Math.max(0, detailOffset - pageSize);
|
|
211
|
+
redraw();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (data === "\x1b[6~" || isKey(data, "pageDown")) {
|
|
215
|
+
detailOffset = Math.min(maxOffset(), detailOffset + pageSize);
|
|
216
|
+
redraw();
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
invalidate() {},
|
|
220
|
+
dispose() {},
|
|
221
|
+
getSelected() {
|
|
222
|
+
return selected;
|
|
223
|
+
},
|
|
224
|
+
getDetailOffset() {
|
|
225
|
+
return detailOffset;
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Shared task model and persisted store envelope. */
|
|
2
|
+
|
|
3
|
+
export type TaskStatus = "pending" | "in_progress" | "completed";
|
|
4
|
+
|
|
5
|
+
export interface TaskLogEntry {
|
|
6
|
+
/** ISO 8601 UTC timestamp added by the store. */
|
|
7
|
+
timestamp: string;
|
|
8
|
+
/** Caller-supplied execution note. */
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Task {
|
|
13
|
+
/** Numeric, positive, monotonically allocated within a store. Never reused. */
|
|
14
|
+
id: number;
|
|
15
|
+
subject: string;
|
|
16
|
+
description: string;
|
|
17
|
+
/** Assigned agent type shown as `[assignee]` in the widget. */
|
|
18
|
+
assignee?: string;
|
|
19
|
+
/** Optional accent color name for the status glyph. Never interpreted by the store. */
|
|
20
|
+
color?: string;
|
|
21
|
+
status: TaskStatus;
|
|
22
|
+
/** Number of entries into `in_progress`. Starts at 0, increments on pending/completed -> in_progress. */
|
|
23
|
+
attempt: number;
|
|
24
|
+
/** Per-task immutable cap for `attempt`. Defaults to 9. */
|
|
25
|
+
maxAttempts: number;
|
|
26
|
+
/** IDs this task depends on. Defaults to []. */
|
|
27
|
+
blockedBy: number[];
|
|
28
|
+
/** Free-form agent metadata. Updated with shallow merge. Defaults to {}. */
|
|
29
|
+
metadata: Record<string, unknown>;
|
|
30
|
+
/** Append-only timestamped execution notes. Defaults to []. */
|
|
31
|
+
log: TaskLogEntry[];
|
|
32
|
+
/** ISO 8601 UTC timestamp. Set once at creation, never changed. */
|
|
33
|
+
createdAt: string;
|
|
34
|
+
/** ISO 8601 UTC timestamp. Refreshed on every successful update. */
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
/** ISO 8601 UTC timestamp marking the start of the current `in_progress` attempt. Set only on a real non-`in_progress` -> `in_progress` transition; preserved by `in_progress` -> `in_progress` updates; cleared when leaving `in_progress`. Absent on legacy persisted tasks (initialized to the load timestamp on load, so the attempt starts at zero). */
|
|
37
|
+
startedAt?: string;
|
|
38
|
+
/** Frozen duration in integer milliseconds of the last completed attempt. Set on transition into `completed`; cleared on entry into `in_progress`; preserved otherwise. Absent until the first completion. */
|
|
39
|
+
tookMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** On-disk envelope. `version` is always 1. */
|
|
43
|
+
export interface StoreData {
|
|
44
|
+
version: 1;
|
|
45
|
+
nextId: number;
|
|
46
|
+
tasks: Task[];
|
|
47
|
+
/** Wall-clock union milliseconds accumulated while at least one task was `in_progress`. Finished periods only; the running period (if any) starts at `activeSince`. Defaults to 0 for legacy files. */
|
|
48
|
+
totalActiveMs: number;
|
|
49
|
+
/** ISO 8601 UTC start of the current active period. Present iff at least one task is `in_progress`. */
|
|
50
|
+
activeSince?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const TASK_STATUSES: TaskStatus[] = ["pending", "in_progress", "completed"];
|
|
54
|
+
|
|
55
|
+
export function isTaskStatus(value: unknown): value is TaskStatus {
|
|
56
|
+
return value === "pending" || value === "in_progress" || value === "completed";
|
|
57
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentational task rendering. Pure functions over task state: this module
|
|
3
|
+
* never reads or writes the store and never changes lifecycle/model semantics.
|
|
4
|
+
*
|
|
5
|
+
* Each line shows the numeric ID with its `(<attempt>/<maxAttempts>)` counter,
|
|
6
|
+
* the optional `[assignee]`, and the subject. `in_progress` lines append the
|
|
7
|
+
* running per-attempt duration measured from `startedAt` to the supplied
|
|
8
|
+
* current time (`Date.now()` by default; pass an explicit `nowMs` for
|
|
9
|
+
* deterministic rendering/tests), showing `0s` immediately at zero. `completed`
|
|
10
|
+
* lines append only the frozen `<duration>` for the last completed attempt
|
|
11
|
+
* (`tookMs`, or the `createdAt` to `updatedAt` span for legacy tasks).
|
|
12
|
+
* `pending` lines show no duration: a new attempt shows `0s` on entry into
|
|
13
|
+
* `in_progress`.
|
|
14
|
+
* The header always shows the total count and the done count (including
|
|
15
|
+
* `(0 done)`). It appends the global accumulated active (wall-clock union)
|
|
16
|
+
* time only after a task has entered `in_progress`; the themed header renders
|
|
17
|
+
* that time dim/gray. Every status uses a filled `■` glyph; color and text styling
|
|
18
|
+
* distinguish pending, in-progress, and completed tasks. The optional `[assignee]`
|
|
19
|
+
* always uses the same color, weight, and
|
|
20
|
+
* decoration as the subject. Pending assignees and subjects use the default
|
|
21
|
+
* text color while the pending glyph remains gray. The optional task `color`
|
|
22
|
+
* tints only the in-progress and completed status glyphs; both render green
|
|
23
|
+
* by default. Elapsed/completed duration text renders dim/gray in themed lines
|
|
24
|
+
* and is appended plain after the subject in the text fallback.
|
|
25
|
+
* In-progress glyphs blink by alternating with a same-width blank.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
29
|
+
import type { Task } from "./types.js";
|
|
30
|
+
|
|
31
|
+
export type ThemeLike = {
|
|
32
|
+
fg(color: string, text: string): string;
|
|
33
|
+
bold(text: string): string;
|
|
34
|
+
strikethrough(text: string): string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function statusGlyph(_task: Task): string {
|
|
38
|
+
return "■";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatSecDuration(diffSec: number): string {
|
|
42
|
+
if (!Number.isFinite(diffSec) || diffSec < 0) diffSec = 0;
|
|
43
|
+
const days = Math.floor(diffSec / 86400);
|
|
44
|
+
const hours = Math.floor((diffSec % 86400) / 3600);
|
|
45
|
+
const minutes = Math.floor((diffSec % 3600) / 60);
|
|
46
|
+
const seconds = diffSec % 60;
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
if (days > 0) parts.push(`${days}d`);
|
|
49
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
50
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
51
|
+
if (seconds > 0) parts.push(`${seconds}s`);
|
|
52
|
+
return parts.join(" ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format a nonzero day/hour/minute/second elapsed duration from `createdAt`
|
|
57
|
+
* to `nowMs` (defaults to `Date.now()`), in order (e.g. `1d 1h 1m 1s`).
|
|
58
|
+
* Components whose value is zero are omitted; a sub-second (or future)
|
|
59
|
+
* duration yields an empty string. Kept for task-age compatibility; the
|
|
60
|
+
* widget itself measures attempts from `startedAt` and frozen `tookMs`.
|
|
61
|
+
*/
|
|
62
|
+
export function formatElapsedDuration(createdAt: string, nowMs: number = Date.now()): string {
|
|
63
|
+
const createdMs = Date.parse(createdAt);
|
|
64
|
+
return formatSecDuration(Math.floor((nowMs - createdMs) / 1000));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Format a millisecond duration the same way; sub-second (or negative) yields an empty string. */
|
|
68
|
+
export function formatMillisDuration(ms: number): string {
|
|
69
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "";
|
|
70
|
+
return formatSecDuration(Math.floor(ms / 1000));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Wall-clock union timing supplied by the store for the widget header. */
|
|
74
|
+
export interface ActiveTiming {
|
|
75
|
+
totalActiveMs?: number;
|
|
76
|
+
activeSince?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function attemptElapsedMs(task: Task, nowMs: number): number {
|
|
80
|
+
const startMs = Date.parse(task.startedAt ?? task.createdAt);
|
|
81
|
+
if (!Number.isFinite(startMs)) return 0;
|
|
82
|
+
return Math.max(0, nowMs - startMs);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tookDisplay(task: Task): string {
|
|
86
|
+
if (task.tookMs !== undefined) return formatMillisDuration(task.tookMs) || "0s";
|
|
87
|
+
// Legacy completed task without a frozen duration: freeze the
|
|
88
|
+
// pre-upgrade age span so reloads neither reset it nor let it grow.
|
|
89
|
+
const endMs = Date.parse(task.updatedAt);
|
|
90
|
+
const startMs = Date.parse(task.createdAt);
|
|
91
|
+
if (!Number.isFinite(endMs) || !Number.isFinite(startMs)) return "0s";
|
|
92
|
+
return formatSecDuration(Math.floor(Math.max(0, endMs - startMs) / 1000)) || "0s";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function durationSuffix(task: Task, nowMs: number): string {
|
|
96
|
+
if (task.status === "in_progress") {
|
|
97
|
+
const elapsed = formatMillisDuration(attemptElapsedMs(task, nowMs)) || "0s";
|
|
98
|
+
return ` ${elapsed}`;
|
|
99
|
+
}
|
|
100
|
+
if (task.status === "completed") {
|
|
101
|
+
return ` ${tookDisplay(task)}`;
|
|
102
|
+
}
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Pending-only dependency suffix appended after the subject. */
|
|
107
|
+
export function blockedBySuffix(task: Task): string {
|
|
108
|
+
return task.status === "pending" && task.blockedBy.length > 0
|
|
109
|
+
? ` → (${task.blockedBy.join(", ")})`
|
|
110
|
+
: "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resolve the header total: finished union time plus the running slice (if any). Zero renders as `0s`. */
|
|
114
|
+
export function formatActiveTotal(timing: ActiveTiming | undefined, tasks: Task[], nowMs: number): string {
|
|
115
|
+
const base = timing?.totalActiveMs ?? 0;
|
|
116
|
+
let bonus = 0;
|
|
117
|
+
if (timing?.activeSince !== undefined) {
|
|
118
|
+
const startMs = Date.parse(timing.activeSince);
|
|
119
|
+
if (Number.isFinite(startMs)) bonus = Math.max(0, nowMs - startMs);
|
|
120
|
+
} else {
|
|
121
|
+
// Direct rendering without store timing (e.g. legacy tests): derive the
|
|
122
|
+
// running slice from the earliest active attempt so the header still ticks.
|
|
123
|
+
let earliest: number | undefined;
|
|
124
|
+
for (const task of tasks) {
|
|
125
|
+
if (task.status !== "in_progress") continue;
|
|
126
|
+
const startMs = Date.parse(task.startedAt ?? task.createdAt);
|
|
127
|
+
if (Number.isFinite(startMs)) earliest = earliest === undefined ? startMs : Math.min(earliest, startMs);
|
|
128
|
+
}
|
|
129
|
+
if (earliest !== undefined) bonus = Math.max(0, nowMs - earliest);
|
|
130
|
+
}
|
|
131
|
+
return formatMillisDuration(base + bonus) || "0s";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Plain-text line: `#<id> (<attempt>/<maxAttempts>) [assignee] subject`, a pending-only ` → (<blockedBy>)` suffix, and status-specific duration. No colors. */
|
|
135
|
+
export function formatTaskLine(task: Task, nowMs: number = Date.now()): string {
|
|
136
|
+
const assignee = task.assignee !== undefined ? ` [${task.assignee}]` : "";
|
|
137
|
+
return ` ${statusGlyph(task)} #${task.id} (${task.attempt}/${task.maxAttempts})${assignee} ${task.subject}${blockedBySuffix(task)}${durationSuffix(task, nowMs)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface HeaderParts {
|
|
141
|
+
base: string;
|
|
142
|
+
activeTotal?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildHeaderParts(tasks: Task[], nowMs: number, timing?: ActiveTiming): HeaderParts {
|
|
146
|
+
const done = tasks.filter((task) => task.status === "completed").length;
|
|
147
|
+
const taskLabel = tasks.length === 1 ? "task" : "tasks";
|
|
148
|
+
const base = `● ${tasks.length} ${taskLabel} (${done} done)`;
|
|
149
|
+
const hasStarted = tasks.some((task) => task.attempt > 0 || task.status === "in_progress");
|
|
150
|
+
return hasStarted ? { base, activeTotal: formatActiveTotal(timing, tasks, nowMs) } : { base };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Plain-text widget lines. Empty list yields no lines. `nowMs` fixes the elapsed clock for deterministic rendering; `timing` supplies the global union time. */
|
|
154
|
+
export function buildWidgetLines(tasks: Task[], nowMs: number = Date.now(), timing?: ActiveTiming): string[] {
|
|
155
|
+
if (tasks.length === 0) return [];
|
|
156
|
+
const sorted = [...tasks].sort((a, b) => a.id - b.id);
|
|
157
|
+
const { base, activeTotal } = buildHeaderParts(sorted, nowMs, timing);
|
|
158
|
+
const header = activeTotal === undefined ? base : `${base} ${activeTotal}`;
|
|
159
|
+
return [header, ...sorted.map((task) => formatTaskLine(task, nowMs))];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Map a user-supplied task color to a known theme color. Unknown values yield
|
|
164
|
+
* `undefined` (callers fall back to the default glyph color) because
|
|
165
|
+
* `theme.fg` throws on unknown colors.
|
|
166
|
+
*/
|
|
167
|
+
export function themeColorFor(color: string | undefined): string | undefined {
|
|
168
|
+
if (color === undefined) return undefined;
|
|
169
|
+
const name = color.trim().toLowerCase();
|
|
170
|
+
const mapping: Record<string, string> = {
|
|
171
|
+
red: "error",
|
|
172
|
+
green: "success",
|
|
173
|
+
yellow: "warning",
|
|
174
|
+
blue: "accent",
|
|
175
|
+
cyan: "accent",
|
|
176
|
+
magenta: "accent",
|
|
177
|
+
purple: "accent",
|
|
178
|
+
gray: "dim",
|
|
179
|
+
grey: "dim",
|
|
180
|
+
white: "text",
|
|
181
|
+
black: "dim",
|
|
182
|
+
};
|
|
183
|
+
return mapping[name];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Truncate every line to `width` visible columns. Non-positive widths pass through. */
|
|
187
|
+
function fitLinesToWidth(lines: string[], width: number | undefined): string[] {
|
|
188
|
+
if (width === undefined || !Number.isFinite(width) || width <= 0) return lines;
|
|
189
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Themed widget lines. Falls back to plain lines if the theme rejects a color.
|
|
194
|
+
* When `width` is a positive finite number, every emitted line is truncated to
|
|
195
|
+
* fit that many visible columns (ANSI-aware, colors preserved). When
|
|
196
|
+
* `blinkOn` is false, in-progress glyphs render as a same-width blank (the
|
|
197
|
+
* widget toggles this to blink); plain-text lines always show steady glyphs.
|
|
198
|
+
* `nowMs` fixes the elapsed clock for deterministic rendering. Elapsed text
|
|
199
|
+
* always renders dim; the plain fallback already includes it.
|
|
200
|
+
*/
|
|
201
|
+
export function renderWidgetLines(tasks: Task[], theme: ThemeLike, width?: number, blinkOn = true, nowMs: number = Date.now(), timing?: ActiveTiming): string[] {
|
|
202
|
+
const plain = buildWidgetLines(tasks, nowMs, timing);
|
|
203
|
+
if (plain.length === 0) return plain;
|
|
204
|
+
try {
|
|
205
|
+
const sorted = [...tasks].sort((a, b) => a.id - b.id);
|
|
206
|
+
const { base, activeTotal } = buildHeaderParts(sorted, nowMs, timing);
|
|
207
|
+
const themedHeader = activeTotal === undefined
|
|
208
|
+
? theme.fg("accent", base)
|
|
209
|
+
: `${theme.fg("accent", base)} ${theme.fg("dim", activeTotal)}`;
|
|
210
|
+
const lines = [themedHeader];
|
|
211
|
+
for (const task of sorted) {
|
|
212
|
+
const assigneeText = task.assignee !== undefined ? ` [${task.assignee}]` : "";
|
|
213
|
+
const idPart = theme.fg("dim", `#${task.id} (${task.attempt}/${task.maxAttempts})`);
|
|
214
|
+
const suffix = durationSuffix(task, nowMs);
|
|
215
|
+
const elapsedSuffix = suffix === "" ? "" : ` ${theme.fg("dim", suffix.trim())}`;
|
|
216
|
+
const completedSubject = theme.fg("dim", task.subject);
|
|
217
|
+
const completedAssignee = assigneeText ? theme.fg("dim", assigneeText) : "";
|
|
218
|
+
if (task.status === "completed") {
|
|
219
|
+
const glyphColor = themeColorFor(task.color) ?? "success";
|
|
220
|
+
lines.push(` ${theme.fg(glyphColor, "■")} ${idPart}${completedAssignee} ${completedSubject}${elapsedSuffix}`);
|
|
221
|
+
} else if (task.status === "in_progress") {
|
|
222
|
+
const glyphColor = themeColorFor(task.color) ?? "success";
|
|
223
|
+
const assignee = assigneeText ? theme.fg("success", theme.bold(assigneeText)) : "";
|
|
224
|
+
lines.push(` ${theme.fg(glyphColor, blinkOn ? "■" : " ")} ${idPart}${assignee} ${theme.fg("success", theme.bold(task.subject))}${elapsedSuffix}`);
|
|
225
|
+
} else {
|
|
226
|
+
const assignee = assigneeText ? theme.fg("text", assigneeText) : "";
|
|
227
|
+
const dependencies = blockedBySuffix(task);
|
|
228
|
+
lines.push(` ${theme.fg("dim", "■")} ${idPart}${assignee} ${theme.fg("text", task.subject)}${dependencies ? theme.fg("dim", dependencies) : ""}${elapsedSuffix}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return fitLinesToWidth(lines, width);
|
|
232
|
+
} catch {
|
|
233
|
+
return fitLinesToWidth(plain, width);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Minimal TUI surface needed to read the live terminal width and request redraws for blinking/elapsed updates. */
|
|
238
|
+
export type TuiWidthLike = {
|
|
239
|
+
terminal?: { columns?: unknown } | undefined;
|
|
240
|
+
requestRender?: ((force?: boolean) => void) | undefined;
|
|
241
|
+
renderNow?: ((force?: boolean) => void) | undefined;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** Blink period for in-progress glyphs. */
|
|
245
|
+
export const BLINK_INTERVAL_MS = 250;
|
|
246
|
+
|
|
247
|
+
/** Redraw period for elapsed durations. Runs only while an in-progress task is shown. */
|
|
248
|
+
export const ELAPSED_INTERVAL_MS = 1000;
|
|
249
|
+
|
|
250
|
+
function hasInProgress(tasks: Task[]): boolean {
|
|
251
|
+
return tasks.some((task) => task.status === "in_progress");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Clock override for deterministic rendering/tests. A fixed epoch-ms pins elapsed output; a function is read on every render for live ticking. */
|
|
255
|
+
export type NowProvider = number | (() => number);
|
|
256
|
+
|
|
257
|
+
function resolveNow(now: NowProvider | undefined, fallback: number | undefined): number {
|
|
258
|
+
// A per-render override wins over the provider clock.
|
|
259
|
+
if (typeof fallback === "number" && Number.isFinite(fallback)) return fallback;
|
|
260
|
+
if (typeof now === "function") {
|
|
261
|
+
try {
|
|
262
|
+
const value = now();
|
|
263
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
264
|
+
} catch {
|
|
265
|
+
// Fall through to Date.now() below.
|
|
266
|
+
}
|
|
267
|
+
} else if (typeof now === "number" && Number.isFinite(now)) {
|
|
268
|
+
return now;
|
|
269
|
+
}
|
|
270
|
+
return Date.now();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Request a widget redraw via `requestRender()`, falling back to `renderNow()`. */
|
|
274
|
+
function requestRedraw(tui: TuiWidthLike): void {
|
|
275
|
+
if (typeof tui?.requestRender === "function") tui.requestRender();
|
|
276
|
+
else if (typeof tui?.renderNow === "function") tui.renderNow();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Widget component factory. Reads the live `tui.terminal.columns` on every
|
|
281
|
+
* render (so lines keep fitting after resizes); an explicit positive render
|
|
282
|
+
* width wins when the TUI passes one. Elapsed durations read the live clock
|
|
283
|
+
* on every render (or the supplied `now` provider / `render()` override for
|
|
284
|
+
* deterministic tests).
|
|
285
|
+
*
|
|
286
|
+
* When the snapshot contains an in-progress task, a 250 ms timer alternates
|
|
287
|
+
* the in-progress glyph with a same-width blank and requests a redraw via
|
|
288
|
+
* `tui.requestRender()` (falling back to `tui.renderNow()`). The blink timer
|
|
289
|
+
* never starts without an in-progress task. A separate 1 s timer requests a
|
|
290
|
+
* redraw only while an in-progress task is shown so elapsed durations stay
|
|
291
|
+
* current. `dispose()` clears all timers, so redraws stop
|
|
292
|
+
* when the host replaces or removes the widget (the host disposes the
|
|
293
|
+
* previous widget on every `setWidget`, including refreshes after store
|
|
294
|
+
* changes and teardown). Follows the `Loader` component pattern in
|
|
295
|
+
* `@earendil-works/pi-tui` (interval + `requestRender`, cleared on stop).
|
|
296
|
+
*/
|
|
297
|
+
export function createTaskWidget(
|
|
298
|
+
snapshot: Task[],
|
|
299
|
+
tui: TuiWidthLike,
|
|
300
|
+
theme: ThemeLike,
|
|
301
|
+
now?: NowProvider,
|
|
302
|
+
timing?: ActiveTiming,
|
|
303
|
+
): { render: (width?: number, nowMs?: number) => string[]; invalidate: () => void; dispose: () => void } {
|
|
304
|
+
let blinkOn = true;
|
|
305
|
+
let blinkTimer: ReturnType<typeof setInterval> | undefined;
|
|
306
|
+
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
|
307
|
+
if (hasInProgress(snapshot)) {
|
|
308
|
+
blinkTimer = setInterval(() => {
|
|
309
|
+
blinkOn = !blinkOn;
|
|
310
|
+
requestRedraw(tui);
|
|
311
|
+
}, BLINK_INTERVAL_MS);
|
|
312
|
+
}
|
|
313
|
+
if (hasInProgress(snapshot)) {
|
|
314
|
+
elapsedTimer = setInterval(() => {
|
|
315
|
+
requestRedraw(tui);
|
|
316
|
+
}, ELAPSED_INTERVAL_MS);
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
render: (width?: number, nowMs?: number) => {
|
|
320
|
+
const live = tui?.terminal?.columns;
|
|
321
|
+
const resolved =
|
|
322
|
+
typeof width === "number" && Number.isFinite(width) && width > 0
|
|
323
|
+
? width
|
|
324
|
+
: typeof live === "number" && Number.isFinite(live) && live > 0
|
|
325
|
+
? live
|
|
326
|
+
: undefined;
|
|
327
|
+
return renderWidgetLines(snapshot, theme, resolved, blinkOn, resolveNow(now, nowMs), timing);
|
|
328
|
+
},
|
|
329
|
+
invalidate: () => {},
|
|
330
|
+
dispose: () => {
|
|
331
|
+
if (blinkTimer !== undefined) {
|
|
332
|
+
clearInterval(blinkTimer);
|
|
333
|
+
blinkTimer = undefined;
|
|
334
|
+
}
|
|
335
|
+
if (elapsedTimer !== undefined) {
|
|
336
|
+
clearInterval(elapsedTimer);
|
|
337
|
+
elapsedTimer = undefined;
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|