@matthewfl/pi-jtodo 0.0.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.
- package/LICENSE +19 -0
- package/README.md +83 -0
- package/package.json +48 -0
- package/src/config.ts +71 -0
- package/src/constants.ts +78 -0
- package/src/gates.ts +771 -0
- package/src/index.ts +873 -0
- package/src/model.ts +155 -0
- package/src/normalize.ts +122 -0
- package/src/schema.ts +101 -0
- package/src/viewer.ts +136 -0
- package/src/watchdog.ts +71 -0
- package/src/widget.ts +378 -0
- package/tests/test-todo.cjs +1040 -0
package/src/widget.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Above-editor todo widget — the compact, always-on strip showing the
|
|
3
|
+
* current branch's todo state, rendered with ctx.ui.setWidget and capped
|
|
4
|
+
* at a small line budget (default 6) so it never crowds the editor.
|
|
5
|
+
* Pi-only surface; jcode has no equivalent of pi's widget API.
|
|
6
|
+
*
|
|
7
|
+
* Two columns within the same row budget:
|
|
8
|
+
* left — the todo list itself (header + value-ranked item lines)
|
|
9
|
+
* right — a goals table with spelled-out headers (Group / Settled /
|
|
10
|
+
* Conf / Loop / Own): per-goal progress, the group's current
|
|
11
|
+
* completion-confidence average, and its feedback-loop and
|
|
12
|
+
* end-to-end-ownership scores. Rows are ranked by "what would
|
|
13
|
+
* cause the agent to get poked if it stopped right now".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
import { QUALITY_GATE_THRESHOLD } from "./constants.js";
|
|
19
|
+
import { todoConfidenceSummary } from "./gates.js";
|
|
20
|
+
import { goalGroupKey, type TodoGoal, type TodoItem, type TodoState } from "./model.js";
|
|
21
|
+
|
|
22
|
+
export const TODO_WIDGET_ID = "pi-jtodo";
|
|
23
|
+
|
|
24
|
+
export interface TodoWidgetComponent {
|
|
25
|
+
render(width: number): string[];
|
|
26
|
+
invalidate(): void;
|
|
27
|
+
dispose?(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Machine state the header tail should surface. */
|
|
31
|
+
export interface WidgetRuntime {
|
|
32
|
+
armed: boolean;
|
|
33
|
+
gateAttempts: number;
|
|
34
|
+
gateMaxAttempts: number;
|
|
35
|
+
/** Ids of the todos that caused the most recent poke/gate challenge */
|
|
36
|
+
pokeTargets?: ReadonlySet<string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SETTLED = new Set(["completed", "cancelled"]);
|
|
40
|
+
|
|
41
|
+
// Pin the pass check to an unambiguous true green: this theme's "success"
|
|
42
|
+
// swatch is xterm-256 143 (olive #afd75f), which reads as yellow, and the
|
|
43
|
+
// theme ships no other green token. 38;5;40 = #00d700.
|
|
44
|
+
const GREEN_CHECK = "\x1b[38;5;40m✔\x1b[39m";
|
|
45
|
+
const GUTTER = 3;
|
|
46
|
+
const INTENTION_GAP = 12;
|
|
47
|
+
const TABLE_MAX_FRAC = 0.46; // the right table never takes more than ~half the width
|
|
48
|
+
const NAME_MAX = 18; // "(ungrouped)" (11) fits without truncation
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Left column: header + item lines
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
export function renderTodoWidgetLines(
|
|
55
|
+
state: TodoState,
|
|
56
|
+
runtime: WidgetRuntime,
|
|
57
|
+
maxLines: number,
|
|
58
|
+
width: number,
|
|
59
|
+
th: Pick<Theme, "fg" | "bold">,
|
|
60
|
+
): string[] {
|
|
61
|
+
if (state.todos.length === 0 || maxLines < 2) return [];
|
|
62
|
+
const w = Math.max(10, width);
|
|
63
|
+
|
|
64
|
+
const table = buildGoalsTable(state, maxLines, th);
|
|
65
|
+
|
|
66
|
+
// Decide whether the table fits beside the list.
|
|
67
|
+
let tableWidth = 0;
|
|
68
|
+
if (table.length > 0) {
|
|
69
|
+
tableWidth = Math.max(...table.map(visibleWidth));
|
|
70
|
+
const leftWidth = w - tableWidth - GUTTER;
|
|
71
|
+
if (tableWidth > Math.floor(w * TABLE_MAX_FRAC) || leftWidth < 24) {
|
|
72
|
+
tableWidth = 0; // drop the table entirely rather than cramp the list
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const leftWidth = tableWidth > 0 ? w - tableWidth - GUTTER : w;
|
|
76
|
+
|
|
77
|
+
const leftLines = buildLeftColumn(state, runtime, maxLines, leftWidth, th);
|
|
78
|
+
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
for (let i = 0; i < leftLines.length; i++) {
|
|
81
|
+
const left = truncateToWidth(leftLines[i], leftWidth);
|
|
82
|
+
const right = tableWidth > 0 ? (table[i] ?? "") : "";
|
|
83
|
+
if (!right) {
|
|
84
|
+
out.push(truncateToWidth(left, w));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const gap = leftWidth - visibleWidth(left) + GUTTER;
|
|
88
|
+
out.push(left + " ".repeat(Math.max(1, gap)) + right);
|
|
89
|
+
}
|
|
90
|
+
return out.map((l) => truncateToWidth(l, w));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildLeftColumn(
|
|
94
|
+
state: TodoState,
|
|
95
|
+
runtime: WidgetRuntime,
|
|
96
|
+
maxLines: number,
|
|
97
|
+
leftWidth: number,
|
|
98
|
+
th: Pick<Theme, "fg" | "bold">,
|
|
99
|
+
): string[] {
|
|
100
|
+
const settledCount = state.todos.filter((t) => SETTLED.has(t.status)).length;
|
|
101
|
+
const allSettled = settledCount === state.todos.length;
|
|
102
|
+
const summary = todoConfidenceSummary(state.todos);
|
|
103
|
+
|
|
104
|
+
// Machine mode text (bottom line): the vocabulary of the notices the
|
|
105
|
+
// user has been seeing.
|
|
106
|
+
let status: string;
|
|
107
|
+
if (!allSettled) {
|
|
108
|
+
status = runtime.armed ? "· auto-poke" : "· poke off";
|
|
109
|
+
} else if (summary.needs_validation) {
|
|
110
|
+
status =
|
|
111
|
+
runtime.gateAttempts > 0
|
|
112
|
+
? `· gate ${runtime.gateAttempts}/${runtime.gateMaxAttempts}`
|
|
113
|
+
: "· gate";
|
|
114
|
+
} else {
|
|
115
|
+
status = "· done";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Header: the plan's user intention (what the agent thinks the user
|
|
119
|
+
// wants), truncated to the column. The settled count and machine mode
|
|
120
|
+
// moved to the bottom line, so the header answers "why", not "how far".
|
|
121
|
+
const intention = (state.plan.user_intention ?? "").replace(/\s+/g, " ").trim();
|
|
122
|
+
let header = th.fg("accent", th.bold("▣ Todos"));
|
|
123
|
+
if (intention) {
|
|
124
|
+
// INTENTION_GAP keeps a visual gap between the header text and the
|
|
125
|
+
// right-hand goals table (user preference: ~12 cells of air).
|
|
126
|
+
const avail = leftWidth - "▣ Todos".length - 1 - INTENTION_GAP;
|
|
127
|
+
if (avail >= 1) {
|
|
128
|
+
const text =
|
|
129
|
+
intention.length > avail ? `${intention.slice(0, Math.max(1, avail - 1))}…` : intention;
|
|
130
|
+
header += " " + th.fg("muted", text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Value-ranked ordering: the strip truncates the tail, so the tail must
|
|
135
|
+
// be the least valuable. in_progress (the live work) always leads, then
|
|
136
|
+
// pending in declaration order (roughly plan order), completed after
|
|
137
|
+
// that, cancelled last. Stable sort keeps declaration order as tiebreak.
|
|
138
|
+
const RANK = new Map<string, number>([
|
|
139
|
+
["in_progress", 0],
|
|
140
|
+
["pending", 1],
|
|
141
|
+
["completed", 2],
|
|
142
|
+
["cancelled", 3],
|
|
143
|
+
]);
|
|
144
|
+
const ordered = state.todos
|
|
145
|
+
.map((t, i) => ({ t, i }))
|
|
146
|
+
.sort((a, b) => (RANK.get(a.t.status) ?? 1) - (RANK.get(b.t.status) ?? 1) || a.i - b.i)
|
|
147
|
+
.map((x) => x.t);
|
|
148
|
+
|
|
149
|
+
// Budget: header(1) + items + bottom status line (always present).
|
|
150
|
+
const itemBudget = maxLines - 2;
|
|
151
|
+
const shown = ordered.slice(0, Math.max(0, itemBudget));
|
|
152
|
+
const hidden = ordered.length - shown.length;
|
|
153
|
+
|
|
154
|
+
const lines: string[] = [header];
|
|
155
|
+
for (const t of shown) {
|
|
156
|
+
lines.push(formatItemLine(t, th, runtime.pokeTargets));
|
|
157
|
+
}
|
|
158
|
+
let overflow = "";
|
|
159
|
+
if (hidden > 0) {
|
|
160
|
+
const hiddenOpen = ordered.slice(ordered.length - hidden);
|
|
161
|
+
const pend = hiddenOpen.filter((t) => t.status !== "completed" && t.status !== "cancelled").length;
|
|
162
|
+
overflow = ` · … ${hidden} more${pend > 0 ? ` (${pend} open)` : ""}`;
|
|
163
|
+
}
|
|
164
|
+
lines.push(th.fg("dim", ` ${settledCount}/${state.todos.length} settled ${status}${overflow} — /todos`));
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Item lines carry the visual pass/warn semantics:
|
|
170
|
+
* 🚧 in progress 🔲 pending ❌ cancelled
|
|
171
|
+
* ✔ (green) completed at the bar 🟡 (yellow) completed BELOW the bar (gate bait)
|
|
172
|
+
* plus a 👉 prefix on the items that caused the most recent poke.
|
|
173
|
+
*/
|
|
174
|
+
function itemStatus(t: TodoItem): { icon: string; textColor: "success" | "error" | "accent" | "dim" | "warning"; failing: boolean } {
|
|
175
|
+
if (t.status === "completed") {
|
|
176
|
+
const conf = t.completion_confidence;
|
|
177
|
+
if (conf === undefined || conf < QUALITY_GATE_THRESHOLD) {
|
|
178
|
+
// 🟡 yellow light for "gate bait": user-verified that ⚠️ (VS16)
|
|
179
|
+
// cannot be spacing-fixed — the wide glyph hides the name-column
|
|
180
|
+
// shift while pushing the numbers +1. 🟡 is a true double-width
|
|
181
|
+
// single-codepoint emoji: no VS16, no cell-eating, grid-exact.
|
|
182
|
+
return { icon: "🟡", textColor: "warning", failing: true };
|
|
183
|
+
}
|
|
184
|
+
// ✔ (no VS16) — GREEN_CHECK pins the true-green color at render time.
|
|
185
|
+
return { icon: "✔", textColor: "success", failing: false };
|
|
186
|
+
}
|
|
187
|
+
if (t.status === "cancelled") return { icon: "❌", textColor: "error", failing: false };
|
|
188
|
+
if (t.status === "in_progress") return { icon: "🚧", textColor: "accent", failing: false };
|
|
189
|
+
return { icon: "🔲", textColor: "dim", failing: false };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatItemLine(
|
|
193
|
+
t: TodoItem,
|
|
194
|
+
th: Pick<Theme, "fg" | "bold">,
|
|
195
|
+
pokeTargets: ReadonlySet<string> | undefined,
|
|
196
|
+
): string {
|
|
197
|
+
const { icon, textColor, failing } = itemStatus(t);
|
|
198
|
+
const iconText = t.status === "completed" && !failing ? GREEN_CHECK : th.fg(textColor, icon);
|
|
199
|
+
// Uniform 2-cell icon column: 🚧/🔲/❌/🟡 are true double-width emoji;
|
|
200
|
+
// ✔ is a single-cell glyph and gets padded. Every icon cell is
|
|
201
|
+
// exactly 2 columns, so every column after it aligns on all fonts.
|
|
202
|
+
const TWO_CELL = new Set(["🚧", "🔲", "❌", "🟡"]);
|
|
203
|
+
const iconCell = TWO_CELL.has(icon) ? iconText : iconText + " ";
|
|
204
|
+
const poked = pokeTargets?.has(t.id) ? "👉 " : "";
|
|
205
|
+
const text = SETTLED.has(t.status)
|
|
206
|
+
? failing
|
|
207
|
+
? th.fg("warning", t.content)
|
|
208
|
+
: th.fg("dim", t.content)
|
|
209
|
+
: t.content;
|
|
210
|
+
const group = t.group ? th.fg("dim", `[${t.group}] `) : "";
|
|
211
|
+
const conf = t.status === "completed" ? t.completion_confidence : t.confidence;
|
|
212
|
+
const tail =
|
|
213
|
+
conf !== undefined && conf !== null
|
|
214
|
+
? failing
|
|
215
|
+
? th.fg("warning", ` · conf ${conf}`)
|
|
216
|
+
: th.fg("dim", ` · conf ${conf}`)
|
|
217
|
+
: "";
|
|
218
|
+
return ` ${poked}${iconCell} ${th.fg("accent", `#${t.id}`)} ${group}${text}${tail}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ============================================================================
|
|
222
|
+
// Right column: goals table
|
|
223
|
+
// ============================================================================
|
|
224
|
+
|
|
225
|
+
interface GoalRow {
|
|
226
|
+
name: string;
|
|
227
|
+
settled: number;
|
|
228
|
+
total: number;
|
|
229
|
+
done: boolean;
|
|
230
|
+
conf: number | undefined;
|
|
231
|
+
loop: number | undefined;
|
|
232
|
+
own: number | undefined;
|
|
233
|
+
rank: number;
|
|
234
|
+
decl: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function goalTableRows(state: TodoState): GoalRow[] {
|
|
238
|
+
const rows: GoalRow[] = [];
|
|
239
|
+
// The table is driven by the UNION of goal entries and todo group labels:
|
|
240
|
+
// a group written into todos but not yet assessed in `goals` must still
|
|
241
|
+
// render (with "–" scores) — otherwise the right side looks like it
|
|
242
|
+
// "doesn't start displaying" whenever the agent adds new grouped work
|
|
243
|
+
// before it next updates its goal assessments.
|
|
244
|
+
const goalByKey = new Map<string | null, { g: TodoGoal; decl: number }>();
|
|
245
|
+
(state.goals ?? []).forEach((g: TodoGoal, decl) => {
|
|
246
|
+
goalByKey.set(goalGroupKey(g.group), { g, decl });
|
|
247
|
+
});
|
|
248
|
+
const keys: { key: string | null; decl: number }[] = [];
|
|
249
|
+
for (const [key, entry] of goalByKey) keys.push({ key, decl: entry.decl });
|
|
250
|
+
state.todos.forEach((t, i) => {
|
|
251
|
+
const key = goalGroupKey(t.group);
|
|
252
|
+
if (!goalByKey.has(key) && !keys.some((k) => k.key === key)) {
|
|
253
|
+
keys.push({ key, decl: (state.goals?.length ?? 0) + i });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
for (const { key, decl } of keys) {
|
|
257
|
+
const items = state.todos.filter((t) => goalGroupKey(t.group) === key);
|
|
258
|
+
if (items.length === 0) continue; // goals without work in this branch stay hidden
|
|
259
|
+
const g = goalByKey.get(key)?.g;
|
|
260
|
+
const settled = items.filter((t) => SETTLED.has(t.status)).length;
|
|
261
|
+
const done = settled === items.length;
|
|
262
|
+
const summary = todoConfidenceSummary(items);
|
|
263
|
+
const owns = g?.end_to_end_ownership;
|
|
264
|
+
const open = items.some((t) => t.status === "in_progress")
|
|
265
|
+
? 0
|
|
266
|
+
: !done
|
|
267
|
+
? 1
|
|
268
|
+
: summary.needs_validation || owns === undefined || owns < QUALITY_GATE_THRESHOLD
|
|
269
|
+
? 2 // settled but a gate would still act on it
|
|
270
|
+
: 3;
|
|
271
|
+
rows.push({
|
|
272
|
+
name: key ?? "(ungrouped)",
|
|
273
|
+
settled,
|
|
274
|
+
total: items.length,
|
|
275
|
+
done,
|
|
276
|
+
conf: summary.completion_average,
|
|
277
|
+
loop: g?.closed_feedback_loop,
|
|
278
|
+
own: owns,
|
|
279
|
+
rank: open,
|
|
280
|
+
decl,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
rows.sort((a, b) => a.rank - b.rank || a.decl - b.decl);
|
|
284
|
+
return rows;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Table lines: header row first, then one row per goal (+ overflow row). */
|
|
288
|
+
export function buildGoalsTable(
|
|
289
|
+
state: TodoState,
|
|
290
|
+
maxLines: number,
|
|
291
|
+
th: Pick<Theme, "fg" | "bold">,
|
|
292
|
+
): string[] {
|
|
293
|
+
const rows = goalTableRows(state);
|
|
294
|
+
const rowBudget = maxLines - 1; // header row consumes one line
|
|
295
|
+
if (rows.length === 0 || rowBudget < 2) return [];
|
|
296
|
+
|
|
297
|
+
const nameW = Math.min(
|
|
298
|
+
NAME_MAX,
|
|
299
|
+
Math.max("Todo Goal".length, ...rows.map((r) => Math.min(r.name.length, NAME_MAX))),
|
|
300
|
+
);
|
|
301
|
+
// Fixed-width columns joined by single spaces: headers and cells pad to
|
|
302
|
+
// the same widths, so rows and headings share column edges. Own is 4
|
|
303
|
+
// wide (values reach "100%"); the ✔ marker is padded to 2 cells so it
|
|
304
|
+
// matches the double-width emoji markers visually.
|
|
305
|
+
const header =
|
|
306
|
+
" " + ["Todo Goal".padEnd(nameW), "Settled", "Conf", "Own ", "Feedback"].join(" ");
|
|
307
|
+
|
|
308
|
+
const dataBudget = rowBudget - (rows.length > rowBudget ? 1 : 0);
|
|
309
|
+
let shown = rows;
|
|
310
|
+
let footer = "";
|
|
311
|
+
if (rows.length > rowBudget) {
|
|
312
|
+
const hidden = rows.slice(dataBudget);
|
|
313
|
+
shown = rows.slice(0, Math.max(0, dataBudget));
|
|
314
|
+
const openLeft = hidden.filter((r) => !r.done).length;
|
|
315
|
+
footer = `… ${hidden.length} more group${hidden.length === 1 ? "" : "s"}${openLeft > 0 ? ` (${openLeft} open)` : ""}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lines: string[] = [th.fg("dim", header)];
|
|
319
|
+
for (const r of shown) {
|
|
320
|
+
// Row marker: the group's machine-facing state, mirroring the item icons.
|
|
321
|
+
const marker =
|
|
322
|
+
r.done && r.rank < 3
|
|
323
|
+
? th.fg("warning", "🟡") // true 2-cell emoji, same width class as GREEN_CHECK+" "
|
|
324
|
+
: r.done
|
|
325
|
+
? GREEN_CHECK + " " // widen the 1-cell glyph to the emoji width
|
|
326
|
+
: r.rank === 0
|
|
327
|
+
? "🚧"
|
|
328
|
+
: "🔲";
|
|
329
|
+
const name = r.name.length > nameW ? `${r.name.slice(0, Math.max(0, nameW - 1))}…` : r.name;
|
|
330
|
+
const namePadded = name.padEnd(nameW);
|
|
331
|
+
const gatedCell = (v: number | undefined, width: number) => {
|
|
332
|
+
if (v === undefined) return th.fg("dim", "–".padStart(width));
|
|
333
|
+
const cell = `${v}%`.padStart(width);
|
|
334
|
+
return v < QUALITY_GATE_THRESHOLD ? th.fg("warning", cell) : th.fg("success", cell);
|
|
335
|
+
};
|
|
336
|
+
const cells =
|
|
337
|
+
" " + // join between name column and Settled, matching the header
|
|
338
|
+
th.fg("dim", `${(r.settled + "/" + r.total).padStart(7)}`) +
|
|
339
|
+
" " +
|
|
340
|
+
gatedCell(r.conf, 4) +
|
|
341
|
+
" " +
|
|
342
|
+
gatedCell(r.own, 4) +
|
|
343
|
+
" " +
|
|
344
|
+
gatedCell(r.loop, 8);
|
|
345
|
+
const styledName = r.done
|
|
346
|
+
? r.rank < 3
|
|
347
|
+
? th.fg("warning", namePadded)
|
|
348
|
+
: th.fg("dim", namePadded)
|
|
349
|
+
: r.rank === 0
|
|
350
|
+
? th.fg("accent", namePadded)
|
|
351
|
+
: namePadded;
|
|
352
|
+
lines.push(`${marker} ${styledName}${cells}`);
|
|
353
|
+
}
|
|
354
|
+
if (footer) lines.push(th.fg("dim", footer));
|
|
355
|
+
return lines;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function makeTodoWidget(
|
|
359
|
+
getState: () => TodoState,
|
|
360
|
+
getRuntime: () => WidgetRuntime,
|
|
361
|
+
maxLines: number,
|
|
362
|
+
): (tui: unknown, theme: Theme) => TodoWidgetComponent {
|
|
363
|
+
return (_tui, theme) => {
|
|
364
|
+
// Stateless render (thunks read live state), but keep honest dirty
|
|
365
|
+
// bookkeeping so any pi-tui caching that consults invalidate() sees a
|
|
366
|
+
// real state transition instead of a no-op.
|
|
367
|
+
let dirty = true;
|
|
368
|
+
return {
|
|
369
|
+
render(width: number) {
|
|
370
|
+
dirty = false;
|
|
371
|
+
return renderTodoWidgetLines(getState(), getRuntime(), maxLines, width, theme);
|
|
372
|
+
},
|
|
373
|
+
invalidate() {
|
|
374
|
+
dirty = true;
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
}
|