kaizen-agent 1.43.2 → 1.43.3
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/cli/board.test.ts +43 -0
- package/cli/install.ts +6 -4
- package/cli/state.test.ts +2 -0
- package/cli/state.ts +4 -3
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +6 -0
- package/skills/kaizen/spec-main.md +4 -1
- package/web/board/chat.ts +29 -6
- package/web/board/markdown.ts +8 -3
- package/web/board/rich.ts +160 -0
- package/web/board.html +1 -1
- package/web/board.js +40 -25
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/board.test.ts
CHANGED
|
@@ -254,3 +254,46 @@ test("a capitalised phrase mid-sentence is prose, not a label", () => {
|
|
|
254
254
|
test("criteria not numbered from 1 keep their own numbers", () => {
|
|
255
255
|
expect(brief("X Goal: g. Criteria: (2) two. (3) three.").parts[1].body).toBe("- (2) two.\n- (3) three.");
|
|
256
256
|
});
|
|
257
|
+
|
|
258
|
+
// pretty(): a one-line idea note splits into one paragraph per label.
|
|
259
|
+
test("pretty splits a labelled plain line in a note by its labels", async () => {
|
|
260
|
+
const { pretty } = await import("../web/board/chat.ts");
|
|
261
|
+
const t = "Parent: run-x add a timezone. OPEN QUESTION (waiting on Johan): recalc old rows? Context: PR #452.";
|
|
262
|
+
expect(pretty(t)).toBe("**Parent:** run-x add a timezone.\n\n**OPEN QUESTION (waiting on Johan):** recalc old rows?\n\n**Context:** PR #452.");
|
|
263
|
+
expect(pretty("just a note")).toBe("just a note");
|
|
264
|
+
expect(pretty("Goal: a\nScope: b")).toBe("Goal: a\nScope: b");
|
|
265
|
+
expect(pretty("Parent: x\ndo y. NOTE: a. Context: b.")).toBe("Parent: x\n\ndo y.\n\n**NOTE:** a.\n\n**Context:** b.");
|
|
266
|
+
expect(pretty("- Goal: a. Scope: b.")).toBe("- Goal: a. Scope: b.");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("the note editor turns its DOM back into the markdown md() rendered it from", async () => {
|
|
270
|
+
const { toMd } = await import("../web/board/rich.ts");
|
|
271
|
+
// A minimal DOM: element nodes from md()'s tag soup, enough for toMd.
|
|
272
|
+
const parse = (h) => {
|
|
273
|
+
const root = { nodeType: 1, nodeName: "DIV", childNodes: [], getAttribute: () => null };
|
|
274
|
+
const stack = [root];
|
|
275
|
+
for (const m of h.matchAll(/<(\/?)(\w+)([^>]*)>|([^<]+)/g)) {
|
|
276
|
+
const top = stack[stack.length - 1];
|
|
277
|
+
if (m[4]) top.childNodes.push({ nodeType: 3, nodeName: "#text", textContent: m[4].replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&") });
|
|
278
|
+
else if (m[1]) stack.pop();
|
|
279
|
+
else {
|
|
280
|
+
const attrs = Object.fromEntries([...m[3].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
|
|
281
|
+
const el = { nodeType: 1, nodeName: m[2].toUpperCase(), childNodes: [], getAttribute: (k) => attrs[k] ?? null };
|
|
282
|
+
Object.defineProperty(el, "textContent", { get: () => el.childNodes.map((c) => c.textContent).join("") });
|
|
283
|
+
top.childNodes.push(el);
|
|
284
|
+
if (!/^(img|br)$/.test(m[2])) stack.push(el);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return root;
|
|
288
|
+
};
|
|
289
|
+
const src = "# Title\n\nsome **bold**, *em* and `code` here \nnext\n\n- one\n- two\n\n1. first\n2. second\n\n [link](http://x)";
|
|
290
|
+
expect(toMd(parse(md(src)))).toBe(src);
|
|
291
|
+
// A fresh line typed straight into the box, then one Chrome wrapped in a div.
|
|
292
|
+
expect(toMd(parse("plain <b>b</b><div>next line</div>"))).toBe("plain **b**\n\nnext line");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("md() renders italic and two-space hard breaks, and leaves globs in code alone", () => {
|
|
296
|
+
expect(md("an *em* word")).toBe("<p>an <i>em</i> word</p>");
|
|
297
|
+
expect(md("`*.ts` and `*.md`, a * b * c")).toBe("<p><code>*.ts</code> and <code>*.md</code>, a * b * c</p>");
|
|
298
|
+
expect(md("one \ntwo\nthree")).toBe("<p>one<br>two three</p>");
|
|
299
|
+
});
|
package/cli/install.ts
CHANGED
|
@@ -96,15 +96,17 @@ if (Bun.argv[2] === "browser") {
|
|
|
96
96
|
process.exit(0);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// `kaizen card add "<
|
|
100
|
-
// to the run it came from.
|
|
99
|
+
// `kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]`: what a run
|
|
100
|
+
// files as new work, linked to the run it came from.
|
|
101
101
|
if (Bun.argv[2] === "card") {
|
|
102
102
|
const { locate, label, addCard } = await import("./state.ts");
|
|
103
103
|
const state = locate() ?? join(home, ".kaizen");
|
|
104
104
|
const args = Bun.argv.slice(4), at = args.indexOf("--parent");
|
|
105
105
|
const parent = at >= 0 ? args.splice(at, 2)[1] : undefined;
|
|
106
|
-
|
|
107
|
-
const
|
|
106
|
+
const nt = args.indexOf("--notes");
|
|
107
|
+
const details = nt >= 0 ? args.splice(nt, 2)[1] : undefined;
|
|
108
|
+
if (Bun.argv[3] !== "add" || !args.length || (at >= 0 && !parent) || (nt >= 0 && !details)) { console.log(`\n kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]\n`); process.exit(1); }
|
|
109
|
+
const r = addCard(state, args.join(" "), parent, details);
|
|
108
110
|
if (typeof r === "string") { console.log(`\n ${r}\n`); process.exit(1); }
|
|
109
111
|
console.log(`\n ${c.green("added")} ${args.join(" ")}\n ${c.dim(`parent ${r.parent} · project ${label(state)}`)}\n`);
|
|
110
112
|
process.exit(0);
|
package/cli/state.test.ts
CHANGED
|
@@ -1228,6 +1228,8 @@ test("parent/child: a Parent line links a run and an idea to the run that filed
|
|
|
1228
1228
|
expect(addCard(st, "next thing")).toEqual({ parent: "2026-01-02-b" });
|
|
1229
1229
|
expect(addCard(st, "next thing")).toMatch(/already/);
|
|
1230
1230
|
expect(addCard(st, "other", "nope")).toMatch(/no such run/);
|
|
1231
|
+
addCard(st, "titled", "2026-01-02-b", "the details\nline two");
|
|
1232
|
+
expect(readInbox(st).find((l) => l.text === "titled")?.notes).toContain("Parent: 2026-01-02-b\nthe details");
|
|
1231
1233
|
expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
|
|
1232
1234
|
rmSync(dirname(st), { recursive: true, force: true });
|
|
1233
1235
|
});
|
package/cli/state.ts
CHANGED
|
@@ -915,8 +915,9 @@ export function appendNote(state: string, target: { id: string } | { text: strin
|
|
|
915
915
|
}
|
|
916
916
|
|
|
917
917
|
// A card filed by a run: an open idea whose note names the run as its parent. The
|
|
918
|
-
// parent is the one named, else the newest run still in flight.
|
|
919
|
-
|
|
918
|
+
// parent is the one named, else the newest run still in flight. `details` go in the
|
|
919
|
+
// idea's notes below the Parent line, so the title stays a title.
|
|
920
|
+
export function addCard(state: string, text: string, parent?: string, details?: string): { parent: string } | string {
|
|
920
921
|
text = text.trim().replace(/\s+/g, " ");
|
|
921
922
|
if (!text) return "empty card";
|
|
922
923
|
const id = parent ?? readRuns(state).find((r) => r.stage !== "done" && r.stage !== "abandoned")?.id;
|
|
@@ -924,7 +925,7 @@ export function addCard(state: string, text: string, parent?: string): { parent:
|
|
|
924
925
|
if (!existsSync(join(state, "runs", id, "state.json"))) return `no such run: ${id}`;
|
|
925
926
|
const lines = readInbox(state);
|
|
926
927
|
if (lines.some((l) => l.status === "open" && l.text === text)) return "that card is already on the board";
|
|
927
|
-
writeInbox(state, [...lines, { status: "open", text, notes: `Parent: ${id}
|
|
928
|
+
writeInbox(state, [...lines, { status: "open", text, notes: [`Parent: ${id}`, details?.trim()].filter(Boolean).join("\n") }]);
|
|
928
929
|
return { parent: id };
|
|
929
930
|
}
|
|
930
931
|
|
package/package.json
CHANGED
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -245,6 +245,12 @@ it. Everything else in this file holds unchanged.
|
|
|
245
245
|
yourself, in that order, reading only each stage's declared inputs and writing its
|
|
246
246
|
artifact before starting the next. The stage table below still applies; you are the
|
|
247
247
|
agent for every row.
|
|
248
|
+
Read `spec-build.md` before building and `spec-main.md` before moving the run to
|
|
249
|
+
`review`: being the main thread does not excuse a stage its contract. Its *Preview
|
|
250
|
+
gate* holds in full — a result that shows on a screen needs a real screenshot under
|
|
251
|
+
`runs/<id>/attachments/` embedded in `## Preview`, taken by driving a headless browser
|
|
252
|
+
against the running app; "no browser session" or "same renderer as before" is not a
|
|
253
|
+
reason, it is the gate failing. Short path or not, no screenshot means not built.
|
|
248
254
|
- `runner: full` — dispatch each stage as its own subagent, as follows.
|
|
249
255
|
|
|
250
256
|
Under `full`, each stage runs as a subagent via the Agent tool, with the agent type
|
|
@@ -183,7 +183,10 @@ failed commit is reported as printed; the run is still `done`. With the setting
|
|
|
183
183
|
|
|
184
184
|
A run may file new work as its own card instead of growing its scope — under either
|
|
185
185
|
runner. Under `full`, subagents only suggest it in their report; the main thread files it,
|
|
186
|
-
from the project folder: `kaizen card add "<
|
|
186
|
+
from the project folder: `kaizen card add "<short title>" --notes "<details>"`. The title
|
|
187
|
+
is what the card shows — a few words, like a commit subject; everything else (scope,
|
|
188
|
+
context, open questions, where it came from) goes in `--notes`, never in the title.
|
|
189
|
+
It links the card to
|
|
187
190
|
the run in flight (`--parent <run id>` names another), and the link follows the card into
|
|
188
191
|
the run it becomes. The board shows the parent's name on the child.
|
|
189
192
|
|
package/web/board/chat.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { mic, stopDictation } from "./dictation.ts";
|
|
|
4
4
|
import { $, html, raw as rawHtml } from "./dom.ts";
|
|
5
5
|
import { DK, draft } from "./drafts.ts";
|
|
6
6
|
import { attach, brief, md } from "./markdown.ts";
|
|
7
|
+
import { rich } from "./rich.ts";
|
|
7
8
|
import { toast } from "./toast.ts";
|
|
8
9
|
|
|
9
10
|
// Notes are a chat: one entry per message, files attached by paste or picker.
|
|
@@ -21,6 +22,23 @@ function parseNotes(src) {
|
|
|
21
22
|
}
|
|
22
23
|
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
23
24
|
}
|
|
25
|
+
// A plain line written as "Label: … Label: …" reads as one paragraph per label.
|
|
26
|
+
// Markdown lines and fenced code are left as written.
|
|
27
|
+
export function pretty(t) {
|
|
28
|
+
let fence = false;
|
|
29
|
+
return t
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((l) => {
|
|
32
|
+
if (/^```/.test(l)) fence = !fence;
|
|
33
|
+
if (fence || /^\s*(#|```|\||[-*]\s|\d+\.\s|>)/.test(l)) return l;
|
|
34
|
+
const b = brief(l);
|
|
35
|
+
if (!b.parts.length) return l;
|
|
36
|
+
const head = l.startsWith(b.parts[0].label + ":") ? [] : [b.head];
|
|
37
|
+
return "\n" + [...head, ...b.parts.map((p) => `**${p.label}:** ${p.body}`)].join("\n\n") + "\n";
|
|
38
|
+
})
|
|
39
|
+
.join("\n")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
24
42
|
let nfocus = null;
|
|
25
43
|
export function chat(c, src, ro) {
|
|
26
44
|
const fix = (t) => attach(t, c);
|
|
@@ -28,9 +46,13 @@ export function chat(c, src, ro) {
|
|
|
28
46
|
const b = c.kind === "idea" ? brief(c.text) : { parts: [] };
|
|
29
47
|
let h =
|
|
30
48
|
(b.parts.length
|
|
31
|
-
? html`<section class="desc"
|
|
49
|
+
? html`<section class="desc">
|
|
50
|
+
${rawHtml(md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n")))}
|
|
51
|
+
</section>`
|
|
32
52
|
: "") +
|
|
33
|
-
html`<div class="chat"
|
|
53
|
+
html`<div class="chat">
|
|
54
|
+
${entries.map((n) => html`<div class="msg">${n.at ? html`<time>${n.at}</time>` : ""}${rawHtml(md(fix(pretty(n.text))))}</div>`)}${entries.length ? "" : html`<p style="color:var(--dim)">No notes yet.</p>`}
|
|
55
|
+
</div>`;
|
|
34
56
|
if (!ro)
|
|
35
57
|
h += `<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
|
|
36
58
|
$("pbody").innerHTML = rawHtml(h);
|
|
@@ -38,11 +60,12 @@ export function chat(c, src, ro) {
|
|
|
38
60
|
const ta = $("pbody").querySelector("textarea"),
|
|
39
61
|
files = [];
|
|
40
62
|
draft(ta, "n:" + key(c));
|
|
63
|
+
const ed = rich(ta);
|
|
41
64
|
// Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
|
|
42
65
|
// so the note shows, and the new box takes focus back for the next note.
|
|
43
66
|
if (nfocus === key(c)) {
|
|
44
67
|
nfocus = null;
|
|
45
|
-
|
|
68
|
+
ed.focus();
|
|
46
69
|
}
|
|
47
70
|
{
|
|
48
71
|
const m = mic([ta]);
|
|
@@ -60,7 +83,7 @@ export function chat(c, src, ro) {
|
|
|
60
83
|
[...e.target.files].forEach(add);
|
|
61
84
|
e.target.value = "";
|
|
62
85
|
};
|
|
63
|
-
|
|
86
|
+
ed.onpaste = (e) => {
|
|
64
87
|
for (const it of e.clipboardData.items)
|
|
65
88
|
if (it.kind === "file") {
|
|
66
89
|
const f = it.getAsFile();
|
|
@@ -93,12 +116,12 @@ export function chat(c, src, ro) {
|
|
|
93
116
|
} catch (e) {}
|
|
94
117
|
toast("Noted");
|
|
95
118
|
nfocus = key(c);
|
|
96
|
-
|
|
119
|
+
ed.blur();
|
|
97
120
|
load();
|
|
98
121
|
}
|
|
99
122
|
};
|
|
100
123
|
$("nsend").onclick = send;
|
|
101
|
-
|
|
124
|
+
ed.onkeydown = (e) => {
|
|
102
125
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
103
126
|
e.preventDefault();
|
|
104
127
|
send();
|
package/web/board/markdown.ts
CHANGED
|
@@ -64,7 +64,8 @@ export function planQuestions(src) {
|
|
|
64
64
|
export function brief(t) {
|
|
65
65
|
// A label opens a sentence; only the first may follow the head without punctuation,
|
|
66
66
|
// and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
|
|
67
|
-
const re =
|
|
67
|
+
const re =
|
|
68
|
+
/(^|[.!?)]\s+|\s)((?:[A-Z][A-Z-]+(?: [A-Z][A-Z-]+){1,3}|[A-Z][\w-]*(?: [a-z][\w-]*){0,3})(?: \([^)]*\))?):\s+/g,
|
|
68
69
|
cut = [];
|
|
69
70
|
let m;
|
|
70
71
|
while ((m = re.exec(t)))
|
|
@@ -88,7 +89,7 @@ export function brief(t) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Enough markdown for kaizen's own files: headings, lists, fences, tables,
|
|
91
|
-
// inline code, bold. Everything is escaped first. With `tick`, a backlog item
|
|
92
|
+
// inline code, bold, italic, hard line breaks. Everything is escaped first. With `tick`, a backlog item
|
|
92
93
|
// (`- open: …`, `- done: …`, `- rejected: …`) shows its status as a checkbox.
|
|
93
94
|
export function md(src, tick) {
|
|
94
95
|
const lines = src.split("\n"),
|
|
@@ -99,6 +100,7 @@ export function md(src, tick) {
|
|
|
99
100
|
esc(s)
|
|
100
101
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
101
102
|
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
|
103
|
+
.replace(/(^|[^*])\*([^*\s<][^*<]*?)\*(?!\*)/g, "$1<i>$2</i>")
|
|
102
104
|
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank"><img src="$2" alt="$1"></a>')
|
|
103
105
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
|
104
106
|
const endList = () => {
|
|
@@ -182,7 +184,10 @@ export function md(src, tick) {
|
|
|
182
184
|
i++;
|
|
183
185
|
while (i < lines.length && lines[i].trim() && !/^(#|```|\||\s*[-*]\s|\s*\d+\.\s|---)/.test(lines[i]))
|
|
184
186
|
buf.push(lines[i++]);
|
|
185
|
-
|
|
187
|
+
// A line ending in two spaces is a hard line break.
|
|
188
|
+
out.push(
|
|
189
|
+
`<p>${inl(buf.map((l, k) => (k < buf.length - 1 && / {2}$/.test(l) ? l.trimEnd() + "\0" : l)).join(" ")).replace(/\0 /g, "<br>")}</p>`,
|
|
190
|
+
);
|
|
186
191
|
}
|
|
187
192
|
endList();
|
|
188
193
|
return out.join("");
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { esc } from "./dom.ts";
|
|
2
|
+
import { md } from "./markdown.ts";
|
|
3
|
+
|
|
4
|
+
// The card note box formats as you type: a contenteditable editor over a hidden
|
|
5
|
+
// textarea that stays the field's value, so drafts, dictation and send are unchanged.
|
|
6
|
+
// The editor is turned back into markdown on every edit; typing a markdown pattern
|
|
7
|
+
// (`**b**`, `` `c` ``, `- `, `1. `, `# `) re-renders the editor with md().
|
|
8
|
+
|
|
9
|
+
const ZW = /\u200b/g;
|
|
10
|
+
// Editor DOM to markdown. Paragraph blocks are separated by a blank line, list items
|
|
11
|
+
// by a newline, so md() renders them back as the same blocks.
|
|
12
|
+
export function toMd(root) {
|
|
13
|
+
const inl = (n) => {
|
|
14
|
+
// pre-wrap: a soft break typed in Chrome is a "\n" in the text.
|
|
15
|
+
if (n.nodeType === 3)
|
|
16
|
+
return n.textContent
|
|
17
|
+
.replace(ZW, "")
|
|
18
|
+
.replace(/\u00a0/g, " ")
|
|
19
|
+
.replace(/\n/g, " \n");
|
|
20
|
+
if (n.nodeType !== 1) return "";
|
|
21
|
+
const t = n.nodeName.toLowerCase(),
|
|
22
|
+
kids = () => [...n.childNodes].map(inl).join("");
|
|
23
|
+
if (t === "br") return " \n";
|
|
24
|
+
if (t === "b" || t === "strong") return ((s) => (s.trim() ? `**${s}**` : s))(kids());
|
|
25
|
+
if (t === "i" || t === "em") return ((s) => (s.trim() ? `*${s}*` : s))(kids());
|
|
26
|
+
if (t === "code") return ((s) => (s ? "`" + s + "`" : s))(n.textContent.replace(ZW, ""));
|
|
27
|
+
if (t === "img") return `})`;
|
|
28
|
+
if (t === "a") {
|
|
29
|
+
const img = [...n.childNodes].find((c) => c.nodeName.toLowerCase() === "img");
|
|
30
|
+
return img ? inl(img) : `[${kids()}](${n.getAttribute("href")})`;
|
|
31
|
+
}
|
|
32
|
+
return kids();
|
|
33
|
+
};
|
|
34
|
+
const out = [];
|
|
35
|
+
let buf = "";
|
|
36
|
+
const flush = () => {
|
|
37
|
+
if (buf.trim()) out.push(buf.replace(/^\n+|\s*\n+$/g, ""));
|
|
38
|
+
buf = "";
|
|
39
|
+
};
|
|
40
|
+
for (const n of root.childNodes) {
|
|
41
|
+
const t = n.nodeType === 1 ? n.nodeName.toLowerCase() : "";
|
|
42
|
+
if (/^h[1-6]$/.test(t)) {
|
|
43
|
+
flush();
|
|
44
|
+
out.push("#".repeat(+t[1]) + " " + inl(n).trim());
|
|
45
|
+
} else if (t === "ul" || t === "ol") {
|
|
46
|
+
flush();
|
|
47
|
+
const lis = [...n.childNodes].filter((c) => c.nodeName.toLowerCase() === "li");
|
|
48
|
+
out.push(lis.map((li, i) => (t === "ol" ? `${i + 1}. ` : "- ") + inl(li).trim()).join("\n"));
|
|
49
|
+
} else if (t === "pre") {
|
|
50
|
+
flush();
|
|
51
|
+
out.push("```\n" + n.textContent + "\n```");
|
|
52
|
+
} else if (t === "p" || t === "div") {
|
|
53
|
+
flush();
|
|
54
|
+
buf = toMd(n);
|
|
55
|
+
flush();
|
|
56
|
+
} else buf += inl(n);
|
|
57
|
+
}
|
|
58
|
+
flush();
|
|
59
|
+
return out.join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A line just typed into a markdown pattern md() would format. Text already inside
|
|
63
|
+
// code or bold is not raw markdown: a code span showing `**` must not match.
|
|
64
|
+
const INL = /\*\*[^*]+\*\*|`[^`]+`|(^|[^*])\*[^*\s][^*]*\*(?!\*)/;
|
|
65
|
+
const typed = (line) => {
|
|
66
|
+
const raw = [];
|
|
67
|
+
const walk = (n) => {
|
|
68
|
+
if (n.nodeType === 3) raw.push(n.textContent.replace(/\u00a0/g, " "));
|
|
69
|
+
else if (!/^(CODE|B|STRONG|I|EM)$/.test(n.nodeName)) n.childNodes.forEach(walk);
|
|
70
|
+
};
|
|
71
|
+
walk(line);
|
|
72
|
+
const txt = raw.join("");
|
|
73
|
+
return (
|
|
74
|
+
INL.test(txt) ||
|
|
75
|
+
(!/^(LI|H\d)$/.test(line.nodeName) && /^\s*([-*]|\d+\.|#{1,6})\s/.test(line.textContent.replace(/\u00a0/g, " ")))
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function rich(ta) {
|
|
80
|
+
const ed = document.createElement("div");
|
|
81
|
+
ed.className = "rich";
|
|
82
|
+
ed.contentEditable = "true";
|
|
83
|
+
ed.setAttribute("role", "textbox");
|
|
84
|
+
ed.setAttribute("aria-multiline", "true");
|
|
85
|
+
ed.dataset.ph = ta.placeholder;
|
|
86
|
+
ta.hidden = true;
|
|
87
|
+
ta.after(ed);
|
|
88
|
+
// Top-level lines, list items counted one each: where the caret goes back to.
|
|
89
|
+
const lines = () => [...ed.querySelectorAll(":scope > :not(ul):not(ol), :scope > ul > li, :scope > ol > li")];
|
|
90
|
+
const render = (src) => {
|
|
91
|
+
// An empty item or heading needs a <br> to hold the caret.
|
|
92
|
+
ed.innerHTML = md(src).replace(/<(li|h\d)><\/\1>/g, "<$1><br></$1>");
|
|
93
|
+
for (const l of lines()) {
|
|
94
|
+
const last = l.lastChild;
|
|
95
|
+
if (last && last.nodeType === 1 && /^(B|I|CODE|A)$/.test(last.nodeName)) l.append("\u200b");
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const caretAt = (el) => {
|
|
99
|
+
const r = document.createRange();
|
|
100
|
+
r.selectNodeContents(el || ed);
|
|
101
|
+
r.collapse(false);
|
|
102
|
+
const s = getSelection();
|
|
103
|
+
s.removeAllRanges();
|
|
104
|
+
s.addRange(r);
|
|
105
|
+
};
|
|
106
|
+
let own = false;
|
|
107
|
+
const sync = () => {
|
|
108
|
+
own = true;
|
|
109
|
+
ta.value = toMd(ed);
|
|
110
|
+
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
111
|
+
own = false;
|
|
112
|
+
};
|
|
113
|
+
ed.addEventListener("input", () => {
|
|
114
|
+
const s = getSelection();
|
|
115
|
+
const all = lines();
|
|
116
|
+
const i = s.rangeCount ? all.findIndex((l) => l.contains(s.anchorNode)) : -1;
|
|
117
|
+
const line = all[i] || (s.anchorNode && s.anchorNode.parentNode === ed ? ed : null);
|
|
118
|
+
sync();
|
|
119
|
+
// Emptied: leave no stray <br>, so the placeholder shows again.
|
|
120
|
+
if (!ta.value.trim() && !ed.querySelector("img")) ed.replaceChildren();
|
|
121
|
+
else if (line && typed(line)) {
|
|
122
|
+
// ponytail: whole-editor re-render, caret to the end of the same line; per-line patching if notes grow long.
|
|
123
|
+
const at = i < 0 ? 0 : i;
|
|
124
|
+
render(ta.value);
|
|
125
|
+
caretAt(lines()[at]);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// Dictation or a restored draft changed the value: show it.
|
|
129
|
+
ta.addEventListener("input", () => {
|
|
130
|
+
if (own) return;
|
|
131
|
+
render(ta.value);
|
|
132
|
+
if (document.activeElement === ed) caretAt(null);
|
|
133
|
+
});
|
|
134
|
+
ed.addEventListener("keydown", (e) => {
|
|
135
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
136
|
+
if (mod && e.key.toLowerCase() === "b") {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
document.execCommand("bold");
|
|
139
|
+
} else if (mod && e.key.toLowerCase() === "e") {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
const t = String(getSelection());
|
|
142
|
+
if (t) document.execCommand("insertHTML", false, `<code>${esc(t)}</code>\u200b`);
|
|
143
|
+
} else if (e.key === "Enter" && e.shiftKey) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
// A new item in a list or after a heading; a soft line break in a paragraph.
|
|
146
|
+
const n = getSelection().anchorNode;
|
|
147
|
+
const el = n && (n.nodeType === 1 ? n : n.parentElement);
|
|
148
|
+
document.execCommand(el && el.closest("li,h1,h2,h3,h4,h5,h6") ? "insertParagraph" : "insertLineBreak");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// Files are handled by the caller; text comes in plain, never as foreign HTML.
|
|
152
|
+
ed.addEventListener("paste", (e) => {
|
|
153
|
+
if ([...e.clipboardData.items].some((it) => it.kind === "file")) return;
|
|
154
|
+
const t = e.clipboardData.getData("text/plain");
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
if (t) document.execCommand("insertText", false, t);
|
|
157
|
+
});
|
|
158
|
+
if (ta.value) render(ta.value);
|
|
159
|
+
return ed;
|
|
160
|
+
}
|
package/web/board.html
CHANGED
|
@@ -240,7 +240,7 @@ dialog.kz{border:0;padding:0;background:none;width:min(480px,calc(100vw - 32px))
|
|
|
240
240
|
aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-direction:column}#pbody .term{flex:1;min-height:0;min-height:320px;background:var(--bg);border-radius:var(--r-s)}#pbody .term .xterm{height:100%;padding:4px}#pbody .term .xterm-viewport{background:transparent!important}.termkeys{padding:6px 0}.termkeys button{height:52px;padding:0;border:1px solid var(--line);border-bottom-width:4px;border-radius:10px;background:var(--bg-2);color:var(--ink-hi);font:600 17px var(--mono);touch-action:manipulation}.termkeys button:active{transform:translateY(2px);border-bottom-width:2px;background:var(--hover)}.termkeys button.on{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}.keygrid{display:grid;grid-template-columns:repeat(6,1fr);gap:8px}.ctrlpad{margin-bottom:8px}.ctrlpad div{display:flex;gap:4px;margin-bottom:5px}.ctrlpad div:nth-child(2){padding:0 5%}.ctrlpad div:nth-child(3){padding:0 10%}.ctrlpad button{flex:1;min-width:0;height:44px;font-size:15px}.termkeys [hidden]{display:none}.keytop{display:flex;gap:8px;margin-bottom:6px}.keytop button{flex:1;height:30px;border-bottom-width:1px;font-size:14px}
|
|
241
241
|
.desc{border-bottom:1px solid var(--line);padding-bottom:6px;margin-bottom:14px}.desc h3{margin-top:14px}
|
|
242
242
|
.chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
|
|
243
|
-
.notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
|
|
243
|
+
.notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}.notes .rich{min-height:160px;max-height:50vh;overflow:auto;border:1px solid var(--line);border-radius:var(--r-s);padding:8px 12px;background:var(--bg-2);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}.notes .rich:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}.notes .rich:empty::before{content:attr(data-ph);color:var(--dim)}.notes .rich>:first-child{margin-top:0}
|
|
244
244
|
.inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
|
|
245
245
|
.inl.fix{margin-top:16px}.inl.fix label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.fix label.off{color:var(--dim);cursor:default}.inl.fix label.done span{text-decoration:line-through}.inl.fix input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
|
|
246
246
|
.inl.qa{max-height:60vh;overflow:auto}.inl.qa fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:4px}.inl.qa legend{font-size:13px;font-weight:500;padding:0;margin-bottom:4px}.inl.qa label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.qa label input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
|