kaizen-agent 1.32.0 → 1.33.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 +52 -0
- package/cli/board.test.ts +15 -21
- package/cli/browser.test.ts +3 -0
- package/cli/browser.ts +4 -2
- package/cli/build-web.ts +19 -9
- package/cli/notes.test.ts +4 -2
- package/cli/sources.test.ts +2 -2
- package/cli/state.ts +1 -1
- package/cli/web.ts +11 -3
- package/package.json +5 -2
- package/skills/kaizen/spec.md +5 -0
- package/web/board/agent-term.ts +51 -0
- package/web/board/api.ts +102 -0
- package/web/board/approvals.ts +100 -0
- package/web/board/cards.ts +600 -0
- package/web/board/chat.ts +108 -0
- package/web/board/dictation.ts +300 -0
- package/web/board/dom.ts +5 -0
- package/web/board/drafts.ts +21 -0
- package/web/board/fixes.ts +90 -0
- package/web/board/git.ts +76 -0
- package/web/board/icebox.ts +869 -0
- package/web/board/keys.ts +126 -0
- package/web/board/labels.ts +40 -0
- package/web/board/live.ts +69 -0
- package/web/board/main.ts +19 -0
- package/web/board/markdown.ts +197 -0
- package/web/board/panel.ts +331 -0
- package/web/board/projects.ts +220 -0
- package/web/board/settings.ts +191 -0
- package/web/board/store.ts +33 -0
- package/web/board/toast.ts +32 -0
- package/web/board/update.ts +75 -0
- package/web/board/views.ts +65 -0
- package/web/board.html +5 -985
- package/web/board.js +30 -0
- package/web/notebook.js +1 -1
- package/web/notebook.ts +726 -180
- package/web/notes-lib.ts +51 -15
- package/web/notes.html +1 -1
- package/web/term.js +1 -1
- package/web/term.ts +17 -5
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { store } from "./store.ts";
|
|
2
|
+
import { load } from "./api.ts";
|
|
3
|
+
import { focusedKey, key, nav, render } from "./cards.ts";
|
|
4
|
+
import { $ } from "./dom.ts";
|
|
5
|
+
import { iceNav, srcOpen } from "./icebox.ts";
|
|
6
|
+
import { close, openRun } from "./panel.ts";
|
|
7
|
+
import { keep } from "./projects.ts";
|
|
8
|
+
import { go, view } from "./views.ts";
|
|
9
|
+
|
|
10
|
+
export function initKeys() {
|
|
11
|
+
$("kbd").onclick = () => $("help").classList.add("on");
|
|
12
|
+
$("help").onclick = () => $("help").classList.remove("on");
|
|
13
|
+
document.addEventListener("click", () => {
|
|
14
|
+
if (store.menuKey || store.clearMenu) {
|
|
15
|
+
store.menuKey = null;
|
|
16
|
+
store.clearMenu = false;
|
|
17
|
+
render();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
document.onkeydown = (e) => {
|
|
21
|
+
// The icebox reads its own keys first; a checkbox there keeps them, for x then j.
|
|
22
|
+
if (
|
|
23
|
+
view === "sources" &&
|
|
24
|
+
iceNav &&
|
|
25
|
+
srcOpen() &&
|
|
26
|
+
!$("help").classList.contains("on") &&
|
|
27
|
+
!e.target.matches("input:not([type=checkbox]),select,textarea,[contenteditable]") &&
|
|
28
|
+
!e.target.closest("#src-sec") &&
|
|
29
|
+
iceNav(e)
|
|
30
|
+
)
|
|
31
|
+
return;
|
|
32
|
+
if (e.target.matches("input,select,textarea,[contenteditable]")) return;
|
|
33
|
+
if (e.key === "?") {
|
|
34
|
+
$("help").classList.toggle("on");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (e.key === "/" && $("sq") && $("panel").classList.contains("open")) {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
$("sq").focus();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (e.key === "Escape") {
|
|
43
|
+
$("help").classList.remove("on");
|
|
44
|
+
if (store.mode) {
|
|
45
|
+
store.mode = null;
|
|
46
|
+
render();
|
|
47
|
+
} else close();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (e.key === "n") {
|
|
51
|
+
if (view !== "board") go("board", true);
|
|
52
|
+
store.mode = "new";
|
|
53
|
+
render();
|
|
54
|
+
}
|
|
55
|
+
if (e.key === "a") {
|
|
56
|
+
store.all = !store.all;
|
|
57
|
+
keep(store.all ? null : store.S.dir);
|
|
58
|
+
load();
|
|
59
|
+
}
|
|
60
|
+
const mv = {
|
|
61
|
+
ArrowUp: [0, -1],
|
|
62
|
+
k: [0, -1],
|
|
63
|
+
ArrowDown: [0, 1],
|
|
64
|
+
j: [0, 1],
|
|
65
|
+
ArrowLeft: [-1, 0],
|
|
66
|
+
h: [-1, 0],
|
|
67
|
+
ArrowRight: [1, 0],
|
|
68
|
+
l: [1, 0],
|
|
69
|
+
}[e.key];
|
|
70
|
+
// In a run's panel: left/right between its tabs, up/down scroll it. Other panels keep the browser's arrows.
|
|
71
|
+
const rc =
|
|
72
|
+
mv &&
|
|
73
|
+
store.openKey &&
|
|
74
|
+
!e.altKey &&
|
|
75
|
+
!e.ctrlKey &&
|
|
76
|
+
!e.metaKey &&
|
|
77
|
+
store.S.cards.find((c) => key(c) === store.openKey && c.kind === "run");
|
|
78
|
+
if (rc && $("ptabs").children.length) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
if (mv[0]) {
|
|
81
|
+
const ks = [...$("ptabs").children].map((b) => b.dataset.k).concat($("pterm").hidden ? [] : ["term"]),
|
|
82
|
+
i = ks.indexOf(store.tab),
|
|
83
|
+
t = ks[i < 0 ? (mv[0] > 0 ? 0 : ks.length - 1) : Math.max(0, Math.min(ks.length - 1, i + mv[0]))];
|
|
84
|
+
if (t !== store.tab) {
|
|
85
|
+
store.tab = t;
|
|
86
|
+
openRun(rc, true);
|
|
87
|
+
}
|
|
88
|
+
} else $("pbody").scrollBy({ top: mv[1] * 40 });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
mv &&
|
|
93
|
+
!$("panel").classList.contains("open") &&
|
|
94
|
+
!e.altKey &&
|
|
95
|
+
!e.ctrlKey &&
|
|
96
|
+
!e.metaKey &&
|
|
97
|
+
(e.target === document.body || e.target.matches("#board .card"))
|
|
98
|
+
) {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
nav(...mv);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// The open card, else the focused one.
|
|
104
|
+
const ok = store.openKey || focusedKey(),
|
|
105
|
+
oc = ok && store.S.cards.find((c) => key(c) === ok);
|
|
106
|
+
if (!oc) return;
|
|
107
|
+
if (e.key === "e" && oc.kind === "idea") {
|
|
108
|
+
close();
|
|
109
|
+
store.mode = "edit";
|
|
110
|
+
store.modeKey = key(oc);
|
|
111
|
+
render();
|
|
112
|
+
}
|
|
113
|
+
if (e.key === "x" && !["done", "abandoned", "starting"].includes(oc.status)) {
|
|
114
|
+
close();
|
|
115
|
+
store.mode = "reject";
|
|
116
|
+
store.modeKey = key(oc);
|
|
117
|
+
render();
|
|
118
|
+
}
|
|
119
|
+
if (e.key === "r" && oc.kind === "idea") {
|
|
120
|
+
close();
|
|
121
|
+
store.mode = "run";
|
|
122
|
+
store.modeKey = key(oc);
|
|
123
|
+
render();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const m = 60e3,
|
|
2
|
+
h = 3600e3,
|
|
3
|
+
d = 86400e3;
|
|
4
|
+
export const age = (t, now) => {
|
|
5
|
+
const x = now - t;
|
|
6
|
+
return x < h ? Math.round(x / m) + "m" : x < d ? Math.round(x / h) + "h" : Math.round(x / d) + "d";
|
|
7
|
+
};
|
|
8
|
+
export const NAMES = {
|
|
9
|
+
claude: "Claude Code",
|
|
10
|
+
codex: "Codex",
|
|
11
|
+
agy: "Antigravity",
|
|
12
|
+
opencode: "OpenCode",
|
|
13
|
+
gemini: "Gemini",
|
|
14
|
+
cursor: "Cursor",
|
|
15
|
+
yolo: "yolo",
|
|
16
|
+
unrechecked: "fix not rechecked",
|
|
17
|
+
};
|
|
18
|
+
export const MARKS = { claude: 1, codex: 1, agy: 1, opencode: 1, gemini: 1, cursor: 1, full: 1, lite: 1 };
|
|
19
|
+
export const COLS = ["Ideas", "Planning", "Building", "Review", "Done"];
|
|
20
|
+
// state.ts statuses onto the icon set in the <defs> above.
|
|
21
|
+
export const ICON = {
|
|
22
|
+
idea: "idea",
|
|
23
|
+
starting: "start",
|
|
24
|
+
running: "run",
|
|
25
|
+
stalled: "stall",
|
|
26
|
+
waiting: "wait",
|
|
27
|
+
done: "done",
|
|
28
|
+
abandoned: "aband",
|
|
29
|
+
};
|
|
30
|
+
export const NEED = { "approvals.plan": "plan needs your approval", "approvals.review": "review needs your approval" },
|
|
31
|
+
need = (a) => NEED[a] || a;
|
|
32
|
+
export const WORD = {
|
|
33
|
+
idea: "idea",
|
|
34
|
+
starting: "starting",
|
|
35
|
+
running: "running",
|
|
36
|
+
stalled: "stalled",
|
|
37
|
+
waiting: "waiting on you",
|
|
38
|
+
done: "done",
|
|
39
|
+
abandoned: "abandoned",
|
|
40
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { store } from "./store.ts";
|
|
2
|
+
import { load } from "./api.ts";
|
|
3
|
+
import { $ } from "./dom.ts";
|
|
4
|
+
import { iceBusy, openSources, srcOpen } from "./icebox.ts";
|
|
5
|
+
import { deep } from "./panel.ts";
|
|
6
|
+
import { keep, urlAll } from "./projects.ts";
|
|
7
|
+
import { go, markView } from "./views.ts";
|
|
8
|
+
|
|
9
|
+
// ---- live
|
|
10
|
+
let boot = null;
|
|
11
|
+
function listen() {
|
|
12
|
+
const es = new EventSource("/events");
|
|
13
|
+
es.onmessage = (ev) => {
|
|
14
|
+
if (ev.data === "changed") {
|
|
15
|
+
load(true);
|
|
16
|
+
if (srcOpen() && !iceBusy && !$("src-go")?.disabled && !$("src-add")?.disabled) openSources();
|
|
17
|
+
} else if (ev.data === "reload") location.reload();
|
|
18
|
+
else if (ev.data.startsWith("hello ")) {
|
|
19
|
+
const b = ev.data.slice(6);
|
|
20
|
+
if (boot && b !== boot) location.reload();
|
|
21
|
+
boot = b;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
es.onerror = () => {
|
|
25
|
+
es.close();
|
|
26
|
+
setTimeout(listen, 3000);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Back to the project picked last time, unless the URL says which view it wants. A
|
|
30
|
+
// plain fetch: a project that is gone should fall back to all, not raise a toast.
|
|
31
|
+
// A page named in the address shows from the first paint; it is filled once the board has loaded.
|
|
32
|
+
const v0 = ((v) => (v === "icebox" ? "sources" : v))(new URLSearchParams(location.search).get("view"));
|
|
33
|
+
|
|
34
|
+
export function initLive() {
|
|
35
|
+
// Back from a terminal: what was committed or pushed there touched nothing the server watches.
|
|
36
|
+
addEventListener("focus", () => load(true));
|
|
37
|
+
markView(["settings", "sources"].includes(v0) ? v0 : "board");
|
|
38
|
+
document.body.classList.toggle("paged", ["settings", "sources"].includes(v0));
|
|
39
|
+
document.body.classList.toggle("ice", v0 === "sources");
|
|
40
|
+
(async () => {
|
|
41
|
+
let d;
|
|
42
|
+
try {
|
|
43
|
+
d = localStorage.getItem("kz-proj");
|
|
44
|
+
} catch (e) {}
|
|
45
|
+
if (d && !urlAll)
|
|
46
|
+
try {
|
|
47
|
+
if (
|
|
48
|
+
(
|
|
49
|
+
await fetch("/cd", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ dir: d }),
|
|
53
|
+
})
|
|
54
|
+
).ok
|
|
55
|
+
)
|
|
56
|
+
store.all = false;
|
|
57
|
+
else keep();
|
|
58
|
+
} catch (e) {}
|
|
59
|
+
try {
|
|
60
|
+
const f = JSON.parse(sessionStorage.getItem("kz-form"));
|
|
61
|
+
if (f) [store.mode, store.modeKey] = f;
|
|
62
|
+
} catch (e) {}
|
|
63
|
+
load().then(() => {
|
|
64
|
+
go(v0);
|
|
65
|
+
deep();
|
|
66
|
+
});
|
|
67
|
+
listen();
|
|
68
|
+
})();
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The board page: bundled into web/board.js by `bun run build:web` (iife, so nothing
|
|
2
|
+
// leaks onto window). Each module is one part of the page; this file wires them up in
|
|
3
|
+
// the order the page needs.
|
|
4
|
+
// The page never touches the filesystem. Everything comes from /state and the run
|
|
5
|
+
// files behind /run/:id; every change goes through a POST and comes back as an SSE
|
|
6
|
+
// tick that reloads the board. See design.md for the visual rules.
|
|
7
|
+
import { initPanel } from "./panel.ts";
|
|
8
|
+
import { initViews } from "./views.ts";
|
|
9
|
+
import { initProjects } from "./projects.ts";
|
|
10
|
+
import { initUpdate } from "./update.ts";
|
|
11
|
+
import { initKeys } from "./keys.ts";
|
|
12
|
+
import { initLive } from "./live.ts";
|
|
13
|
+
|
|
14
|
+
initPanel();
|
|
15
|
+
initViews();
|
|
16
|
+
initProjects();
|
|
17
|
+
initUpdate();
|
|
18
|
+
initKeys();
|
|
19
|
+
initLive();
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { esc } from "./dom.ts";
|
|
2
|
+
|
|
3
|
+
// The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself,
|
|
4
|
+
// and its "### How to test" steps.
|
|
5
|
+
// "## Commit" is the message the builder wrote for the work, offered at commit and push.
|
|
6
|
+
function section(impl, name) {
|
|
7
|
+
const m = impl && impl.match(new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$(?![\\s\\S]))`, "m"));
|
|
8
|
+
return m ? m[1].trim() : null;
|
|
9
|
+
}
|
|
10
|
+
export const preview = (impl) => section(impl, "Preview");
|
|
11
|
+
// Links to a card's attachments/ (notes, or screenshots the builder put in Preview) via /file.
|
|
12
|
+
export const attach = (t, c) =>
|
|
13
|
+
t.replace(
|
|
14
|
+
/\]\((attachments\/[^)]+)\)/g,
|
|
15
|
+
(_, p) =>
|
|
16
|
+
`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent((c.kind === "run" ? `runs/${c.id}/` : "") + p)})`,
|
|
17
|
+
);
|
|
18
|
+
export const commitMsg = (impl) => (section(impl, "Commit") || "").replace(/^```\w*\n|\n```$/g, "").trim();
|
|
19
|
+
// A plan's Open questions as a pick list: `Q: question` lines with `- option` lines under
|
|
20
|
+
// each, "(recommended)" marking the default and "multi-select" in the question allowing
|
|
21
|
+
// several. A blank or other line ends a question's options. The format is pinned in
|
|
22
|
+
// spec-plan.md; a section with content in any other shape is null, none at all is [].
|
|
23
|
+
export function planQuestions(src) {
|
|
24
|
+
const lines = (src || "").split("\n"),
|
|
25
|
+
at = lines.findIndex((l) => /^#+\s*(\d+\.\s*)?open questions/i.test(l.trim()));
|
|
26
|
+
if (at < 0) return [];
|
|
27
|
+
const lvl = /^#+/.exec(lines[at].trim())[0].length,
|
|
28
|
+
out = [];
|
|
29
|
+
let cur = null,
|
|
30
|
+
said = false;
|
|
31
|
+
for (const raw of lines.slice(at + 1)) {
|
|
32
|
+
const l = raw.trim(),
|
|
33
|
+
h = /^#+(?=\s)/.exec(l);
|
|
34
|
+
if (h && h[0].length <= lvl) break;
|
|
35
|
+
if (!h && l && !/^```/.test(l) && !/^none\.?$/i.test(l)) said = true;
|
|
36
|
+
const q = /^\**Q\d*\s*[:.)]\**\s*(.+)$/.exec(l);
|
|
37
|
+
if (q) {
|
|
38
|
+
out.push((cur = { q: q[1].trim(), multi: /multi-?select/i.test(q[1]), options: [] }));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const o = /^[-*]\s+(.+)$/.exec(l);
|
|
42
|
+
if (o && cur) {
|
|
43
|
+
const t = o[1].trim();
|
|
44
|
+
cur.options.push({
|
|
45
|
+
text: t,
|
|
46
|
+
rec: /\(recommended\)/i.test(t),
|
|
47
|
+
label: t
|
|
48
|
+
.replace(/\s*\(recommended\)/i, "")
|
|
49
|
+
.split(/\s+[—–-]\s+|:\s/)[0]
|
|
50
|
+
.trim(),
|
|
51
|
+
});
|
|
52
|
+
} else if (cur && cur.options.length) cur = null;
|
|
53
|
+
}
|
|
54
|
+
const qs = out.filter((x) => x.options.length);
|
|
55
|
+
return qs.length || !said ? qs : null;
|
|
56
|
+
}
|
|
57
|
+
// An idea is one inbox line, so a structured request arrives as one wall:
|
|
58
|
+
// "Fix x Problem: … Scope: … Acceptance criteria: (1) … (2) …". Split it, for display
|
|
59
|
+
// only, into the text before the first label and one part per label; inline (1) (2)
|
|
60
|
+
// become a numbered list. Fewer than two labels is plain text and is left whole.
|
|
61
|
+
export function brief(t) {
|
|
62
|
+
// A label opens a sentence; only the first may follow the head without punctuation,
|
|
63
|
+
// and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
|
|
64
|
+
const re = /(^|[.!?)]\s+|\s)([A-Z][\w-]*(?: [a-z][\w-]*){0,3}):\s+/g,
|
|
65
|
+
cut = [];
|
|
66
|
+
let m;
|
|
67
|
+
while ((m = re.exec(t)))
|
|
68
|
+
if (!/^\s+$/.test(m[1]) || (!cut.length && !m[2].includes(" ")))
|
|
69
|
+
cut.push([m.index + m[1].length, m[2], re.lastIndex]);
|
|
70
|
+
if (cut.length < 2) return { head: t, parts: [] };
|
|
71
|
+
// Inline (1) (2) … become a list; numbers that do not run 1, 2, 3 keep their own text.
|
|
72
|
+
const parts = cut.map(([, label, from], i) => {
|
|
73
|
+
const body = t.slice(from, i + 1 < cut.length ? cut[i + 1][0] : t.length).trim(),
|
|
74
|
+
n = body.split(/\s*\((\d+)\)\s+/);
|
|
75
|
+
if (n.length < 5) return { label, body };
|
|
76
|
+
const its = [];
|
|
77
|
+
for (let k = 1; k < n.length; k += 2) its.push([n[k], n[k + 1]]);
|
|
78
|
+
const seq = its.every(([d], k) => +d === k + 1);
|
|
79
|
+
return {
|
|
80
|
+
label,
|
|
81
|
+
body: (n[0] ? n[0] + "\n" : "") + its.map(([d, x], k) => (seq ? `${k + 1}. ${x}` : `- (${d}) ${x}`)).join("\n"),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
return { head: t.slice(0, cut[0][0]).trim() || `${parts[0].label}: ${t.slice(cut[0][2], cut[1][0]).trim()}`, parts };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Enough markdown for kaizen's own files: headings, lists, fences, tables,
|
|
88
|
+
// inline code, bold. Everything is escaped first. With `tick`, a backlog item
|
|
89
|
+
// (`- open: …`, `- done: …`, `- rejected: …`) shows its status as a checkbox.
|
|
90
|
+
export function md(src, tick) {
|
|
91
|
+
const lines = src.split("\n"),
|
|
92
|
+
out = [];
|
|
93
|
+
let i = 0,
|
|
94
|
+
list = null;
|
|
95
|
+
const inl = (s) =>
|
|
96
|
+
esc(s)
|
|
97
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
98
|
+
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
|
99
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank"><img src="$2" alt="$1"></a>')
|
|
100
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
|
101
|
+
const endList = () => {
|
|
102
|
+
if (list) {
|
|
103
|
+
out.push(`</${list}>`);
|
|
104
|
+
list = null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
while (i < lines.length) {
|
|
108
|
+
const l = lines[i];
|
|
109
|
+
if (/^```/.test(l)) {
|
|
110
|
+
endList();
|
|
111
|
+
const buf = [];
|
|
112
|
+
i++;
|
|
113
|
+
while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]);
|
|
114
|
+
out.push(`<pre>${esc(buf.join("\n"))}</pre>`);
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const hm = /^(#{1,6})\s+(.*)$/.exec(l);
|
|
119
|
+
if (hm) {
|
|
120
|
+
endList();
|
|
121
|
+
out.push(`<h${Math.min(hm[1].length, 3)}>${inl(hm[2])}</h${Math.min(hm[1].length, 3)}>`);
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (/^---+$/.test(l.trim())) {
|
|
126
|
+
endList();
|
|
127
|
+
out.push("<hr>");
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (/^\|/.test(l)) {
|
|
132
|
+
endList();
|
|
133
|
+
const rows = [];
|
|
134
|
+
while (i < lines.length && /^\|/.test(lines[i])) rows.push(lines[i++]);
|
|
135
|
+
const cells = (r) =>
|
|
136
|
+
r
|
|
137
|
+
.replace(/^\||\|$/g, "")
|
|
138
|
+
.split("|")
|
|
139
|
+
.map((c) => inl(c.trim()));
|
|
140
|
+
out.push(
|
|
141
|
+
"<table>" +
|
|
142
|
+
rows
|
|
143
|
+
.filter((r) => !/^\|\s*-/.test(r))
|
|
144
|
+
.map(
|
|
145
|
+
(r, n) =>
|
|
146
|
+
`<tr>${cells(r)
|
|
147
|
+
.map((c) => (n ? `<td>${c}</td>` : `<th>${c}</th>`))
|
|
148
|
+
.join("")}</tr>`,
|
|
149
|
+
)
|
|
150
|
+
.join("") +
|
|
151
|
+
"</table>",
|
|
152
|
+
);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const li = /^\s*(?:[-*]|\d+\.)\s+(.*)$/.exec(l);
|
|
156
|
+
if (li) {
|
|
157
|
+
const t = /^\s*\d+\./.test(l) ? "ol" : "ul";
|
|
158
|
+
if (list !== t) {
|
|
159
|
+
endList();
|
|
160
|
+
out.push(`<${t}>`);
|
|
161
|
+
list = t;
|
|
162
|
+
}
|
|
163
|
+
const tk = tick && /^(open|done|rejected):\s*(.*)$/.exec(li[1]);
|
|
164
|
+
out.push(
|
|
165
|
+
tk
|
|
166
|
+
? `<li class="tk${tk[1] === "rejected" ? " rej" : ""}"><label><input type="checkbox" tabindex="-1" aria-disabled="true" onclick="return false"${tk[1] === "done" ? " checked" : ""} aria-label="${tk[1]}"><span>${inl(tk[2])}</span></label></li>`
|
|
167
|
+
: `<li>${inl(li[1])}</li>`,
|
|
168
|
+
);
|
|
169
|
+
i++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!l.trim()) {
|
|
173
|
+
endList();
|
|
174
|
+
i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
endList();
|
|
178
|
+
const buf = [l];
|
|
179
|
+
i++;
|
|
180
|
+
while (i < lines.length && lines[i].trim() && !/^(#|```|\||\s*[-*]\s|\s*\d+\.\s|---)/.test(lines[i]))
|
|
181
|
+
buf.push(lines[i++]);
|
|
182
|
+
out.push(`<p>${inl(buf.join(" "))}</p>`);
|
|
183
|
+
}
|
|
184
|
+
endList();
|
|
185
|
+
return out.join("");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A building run's progress.md, one `- todo|doing|done: text` line per Work-list item.
|
|
189
|
+
// prev: the statuses last drawn for this run; an item whose status moved gets .chg.
|
|
190
|
+
export function progress(src, prev) {
|
|
191
|
+
const items = [...src.matchAll(/^\s*-\s*(todo|doing|done):\s*(.*)$/gm)].map((m) => [m[1], m[2]]);
|
|
192
|
+
if (!items.length) return "";
|
|
193
|
+
const n = items.filter((t) => t[0] === "done").length,
|
|
194
|
+
pct = Math.round((n / items.length) * 100),
|
|
195
|
+
ic = { done: "✓", doing: '<span class="pulse"></span>', todo: "" };
|
|
196
|
+
return `<div class="prog"><div class="ph"><b>${n} of ${items.length} done</b><span>${pct}%</span></div><div class="pbar"><i style="width:${pct}%"></i></div><ol>${items.map(([st, t], i) => `<li class="p-${st}${prev && prev[i] && prev[i] !== st ? " chg" : ""}"><span class="pi">${ic[st]}</span><span>${esc(t).replace(/`([^`]+)`/g, "<code>$1</code>")}</span></li>`).join("")}</ol></div>`;
|
|
197
|
+
}
|