kaizen-agent 1.31.3 → 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.
@@ -0,0 +1,600 @@
1
+ import { store } from "./store.ts";
2
+ import { where } from "./agent-term.ts";
3
+ import { load, post } from "./api.ts";
4
+ import { mic, stopDictation } from "./dictation.ts";
5
+ import { $, esc, ico } from "./dom.ts";
6
+ import { draft, undraft } from "./drafts.ts";
7
+ import { COLS, ICON, MARKS, NAMES, WORD, age, d, need } from "./labels.ts";
8
+ import { brief } from "./markdown.ts";
9
+ import { open, toggleArchive } from "./panel.ts";
10
+ import { ideaCombo, list, newDir, short, showList, showable } from "./projects.ts";
11
+ import { askNotify, toast } from "./toast.ts";
12
+
13
+ let filter = null;
14
+ // A column shows this many cards, then a "more" line; Done is where it matters.
15
+ const FOLD = 6;
16
+ const unfolded = new Set();
17
+ // Per column: "old" puts the oldest first; anything else is newest first.
18
+ let order = (() => {
19
+ try {
20
+ return JSON.parse(localStorage.getItem("kz-order")) || {};
21
+ } catch (e) {
22
+ return {};
23
+ }
24
+ })();
25
+ let archive = false; // showing the archive instead of the board
26
+ // Clear on Done: menu, then cherry-pick mode.
27
+ let picking = false;
28
+ const picked = new Set();
29
+
30
+ // Two ideas may carry the same text; load() numbers the repeats so a key names one card.
31
+ export const key = (c) =>
32
+ c._k || (c.kind === "idea" ? "idea:" + c.state + ":" + c.text : "run:" + c.state + ":" + c.id);
33
+
34
+ function card(c) {
35
+ const k = key(c),
36
+ el = document.createElement("div");
37
+ el.className =
38
+ "card" +
39
+ (k === store.openKey ? " open" : "") +
40
+ (k === store.menuKey ? " menu" : "") +
41
+ (c.archived ? " archived" : "");
42
+ el.tabIndex = 0;
43
+ el.dataset.k = k;
44
+ el.setAttribute("role", "button");
45
+ const st = ICON[c.status] || "idea",
46
+ idt = c.kind === "idea" ? "idea" : c.text;
47
+ // The status icon carries the state; what a waiting run needs is its tooltip.
48
+ const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
49
+ // Footer: words on the left (project, what it needs), icon-only marks on the right.
50
+ const chips = [],
51
+ marks = [],
52
+ mk = (id, t, cls = "") =>
53
+ `<span class="mk${cls}" role="img" title="${esc(t)}" aria-label="${esc(t)}"><svg><use href="#${id}"/></svg></span>`;
54
+ if (store.all) {
55
+ const n = c.where.split("/").filter(Boolean).pop() || c.where;
56
+ chips.push(`<span class="chip proj" title="${esc(c.where)}">${esc(n)}</span>`);
57
+ }
58
+ for (const t of c.tags || []) {
59
+ if (MARKS[t])
60
+ marks.push(
61
+ mk(
62
+ "c-" + t,
63
+ t === "full" ? "Full run: separate reviewer" : t === "lite" ? "Lite run: one session" : NAMES[t] || t,
64
+ t === "full" || t === "lite" ? " accent" : "",
65
+ ),
66
+ );
67
+ else if (t === "yolo")
68
+ marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
69
+ else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
70
+ }
71
+ if (c.pty && c.pty.alive)
72
+ marks.push(mk("i-term", "The agent is running in this card: open it, terminal button at the top", " run"));
73
+ // A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
74
+ // the icebox); its chip names the source.
75
+ const g = c.notes && /^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. Outside text/.exec(c.notes);
76
+ if (g) chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);
77
+ else if (c.notes) chips.push(`<span class="chip">notes</span>`);
78
+ // Information only: the panel's button is where it is committed and pushed.
79
+ if (c.git)
80
+ marks.push(
81
+ mk(
82
+ "c-git",
83
+ c.git === "pushed" ? "Committed & pushed" : "Not committed & pushed yet",
84
+ c.git === "pushed" ? " ok" : " warn",
85
+ ),
86
+ );
87
+ const pick = picking && !archive && c.column === 4;
88
+ el.innerHTML =
89
+ (pick ? `<input type="checkbox" class="pick" ${picked.has(k) ? "checked" : ""} aria-label="Select">` : "") +
90
+ `<div class="c-id"><span class="st d-${st}" role="img" title="${esc(sttl)}" aria-label="${esc(sttl)}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind === "run" ? age(c.moved || store.S.now, store.S.now) : ""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k === store.menuKey}">${ico("more")}</button></div>
91
+ <div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? ` title="${esc(c.text)}"` : ""}>${esc(c.kind === "idea" ? brief(c.text).head : c.title || c.id)}</div>
92
+ ${chips.length || marks.length ? `<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>` : ""}`;
93
+ if (k === store.menuKey) {
94
+ const items = [];
95
+ if (archive) {
96
+ items.push(["restore", "Restore", ico("restore")]);
97
+ items.push(["open", "Open", ico("open")]);
98
+ if (c.kind === "idea") items.push(null, ["delete", "Delete", ico("trash"), "danger"]);
99
+ } else if (c.kind === "idea" && c.status === "idea") {
100
+ items.push(
101
+ ["run", "Run", ico("play")],
102
+ ["edit", "Edit", ico("pen")],
103
+ ...(store.S.projects.some((p) => p.dir !== c.state) ? [["move", "Move to project", ico("ext")]] : []),
104
+ ["archive", "Archive", ico("archive")],
105
+ null,
106
+ ["reject", "Reject", ico("x")],
107
+ ["delete", "Delete", ico("trash"), "danger"],
108
+ );
109
+ } else if (c.kind === "idea") {
110
+ items.push(
111
+ ["open", "Open", ico("open")],
112
+ ["run", "Run again", ico("play")],
113
+ ["archive", "Archive", ico("archive")],
114
+ null,
115
+ ["delete", "Remove", ico("trash"), "danger"],
116
+ );
117
+ } else {
118
+ const openLabel =
119
+ c.status === "waiting"
120
+ ? c.awaiting === "approvals.plan"
121
+ ? "Review plan"
122
+ : c.awaiting === "findings"
123
+ ? "See findings"
124
+ : c.awaiting === "approvals.review"
125
+ ? "Review work"
126
+ : "Open"
127
+ : "Open";
128
+ items.push(["open", openLabel, ico("open")]);
129
+ items.push(["archive", "Archive", ico("archive")]);
130
+ if (c.status === "waiting" || c.status === "running" || c.status === "stalled") {
131
+ items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
132
+ }
133
+ }
134
+ el.innerHTML += `<div class="menu-pop" role="menu">${items.map((i) => (i ? `<button role="menuitem" data-a="${i[0]}" class="${i[3] || ""}">${i[2]}${i[1]}</button>` : "<hr>")).join("")}</div>`;
135
+ }
136
+ el.querySelectorAll("[data-a]").forEach(
137
+ (bt) =>
138
+ (bt.onclick = async (e) => {
139
+ e.stopPropagation();
140
+ const a = bt.dataset.a;
141
+ const was = store.menuKey;
142
+ store.menuKey = null;
143
+ if (a === "menu") {
144
+ store.menuKey = was === k ? null : k;
145
+ render();
146
+ return;
147
+ }
148
+ if (a === "open") {
149
+ open(c);
150
+ return;
151
+ }
152
+ if (a === "archive" || a === "restore") {
153
+ toggleArchive(c, a === "archive");
154
+ return;
155
+ }
156
+ store.mode = a;
157
+ store.modeKey = k;
158
+ render();
159
+ }),
160
+ );
161
+ if (pick) {
162
+ el.classList.toggle("picked", picked.has(k));
163
+ el.onclick = () => {
164
+ picked.has(k) ? picked.delete(k) : picked.add(k);
165
+ render();
166
+ };
167
+ return el;
168
+ }
169
+ el.onclick = () => {
170
+ if (store.menuKey) {
171
+ store.menuKey = null;
172
+ render();
173
+ return;
174
+ }
175
+ open(c);
176
+ };
177
+ el.onkeydown = (e) => {
178
+ if (e.key === "Enter" || e.key === " ") {
179
+ e.preventDefault();
180
+ open(c);
181
+ }
182
+ };
183
+ return el;
184
+ }
185
+
186
+ function inlineForm(c) {
187
+ const li = document.createElement("li");
188
+ li.className = "inl";
189
+ const dir = c.state;
190
+ if (store.mode === "edit") {
191
+ li.innerHTML = `<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes || "")}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;
192
+ li.querySelector("[data-a=s]").onclick = async () => {
193
+ const next = li.querySelector("input").value.trim(),
194
+ notes = li.querySelector("textarea").value.trim();
195
+ store.mode = null;
196
+ if (next && (next !== c.text || notes !== (c.notes || "")))
197
+ await post("/inbox", { op: "edit", dir, text: c.text, next, notes });
198
+ load();
199
+ };
200
+ }
201
+ if (store.mode === "reject") {
202
+ const idea = c.kind === "idea";
203
+ li.innerHTML = `<div class="q">${idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea ? "Reject" : "Abandon"}</button></div>`;
204
+ li.querySelector("[data-a=s]").onclick = async () => {
205
+ const why = li.querySelector("input").value.trim();
206
+ if (!why) {
207
+ toast("A reason is required");
208
+ return;
209
+ }
210
+ store.mode = null;
211
+ const r = idea
212
+ ? await post("/inbox", { op: "reject", dir, text: c.text, reason: why })
213
+ : await post("/abort", { dir, id: c.id, why });
214
+ if (r.ok) toast(idea ? "Rejected" : "Abandoned, reason recorded");
215
+ load();
216
+ };
217
+ }
218
+ if (store.mode === "delete") {
219
+ li.innerHTML = `<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;
220
+ li.querySelector("[data-a=s]").onclick = async () => {
221
+ store.mode = null;
222
+ const r = await post("/inbox", { op: "delete", dir, text: c.text });
223
+ if (r.ok) toast("Deleted");
224
+ load();
225
+ };
226
+ }
227
+ if (store.mode === "move") {
228
+ const ps = store.S.projects.filter((p) => p.dir !== dir);
229
+ li.innerHTML = `<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map((p) => `<option value="${esc(p.dir)}">${esc(p.label)}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;
230
+ li.querySelector("[data-a=s]").onclick = async () => {
231
+ const sel = li.querySelector("select"),
232
+ to = sel.value,
233
+ name = sel.selectedOptions[0]?.textContent;
234
+ store.mode = null;
235
+ const r = await post("/inbox", { op: "move", dir, to, text: c.text });
236
+ if (r.ok) toast(`Moved to ${name}`);
237
+ load();
238
+ };
239
+ }
240
+ if (store.mode === "run") {
241
+ li.innerHTML = `<div class="q">Run "${esc(c.text)}"</div><div class="q dim">Opens your agent with this request in a terminal.</div><div class="q dim">Full plans, builds, and checks with a separate reviewer. Lite does it in one session.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="run-actions"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div></div>`;
242
+ li.querySelector("[data-a=s]").onclick = () => startRun(c, "full");
243
+ li.querySelector("[data-a=l]").onclick = () => startRun(c, "lite");
244
+ runForm(li, c);
245
+ }
246
+ li.querySelector("[data-a=c]").onclick = () => {
247
+ store.mode = null;
248
+ render();
249
+ };
250
+ // Edit and reject take dictation; the run prompt has no field.
251
+ li.querySelectorAll("input,textarea").forEach((f, i) => draft(f, "f:" + store.mode + ":" + store.modeKey + ":" + i));
252
+ if (store.mode !== "run") {
253
+ const m = mic([...li.querySelectorAll("input,textarea")]);
254
+ if (m) li.querySelector(".row").prepend(m);
255
+ }
256
+ const inp = li.querySelector("input");
257
+ if (inp) {
258
+ inp.onkeydown = (e) => {
259
+ if (e.key === "Enter") li.querySelector("[data-a=s]").click();
260
+ if (e.key === "Escape") {
261
+ store.mode = null;
262
+ render();
263
+ }
264
+ };
265
+ setTimeout(() => inp.focus());
266
+ }
267
+ return li;
268
+ }
269
+
270
+ // The agent and Basic/Yolo picked in the open Run form. Kept here, not in the form:
271
+ // the board is rebuilt on every server change, and the choice must outlive that.
272
+ // Yolo never outlives the form: render() drops it once the form is closed.
273
+ let runPick = null;
274
+ // The tool a launch opened, and why when it is not the one asked for.
275
+ export const via = (a) => a.name + (a.note ? ". " + a.note : "");
276
+ async function runForm(li, c) {
277
+ const k = key(c),
278
+ box = li.querySelector("#run-agent");
279
+ const d = await fetch("/agents?dir=" + encodeURIComponent(c.state))
280
+ .then((r) => r.json())
281
+ .catch(() => null);
282
+ if (!d || !d.agents || !d.agents.length) {
283
+ li.querySelector("#run-pick").hidden = true;
284
+ return;
285
+ }
286
+ // Antigravity and Gemini CLI are both Google's agent on Gemini; offer one of them.
287
+ if (d.agents.some((a) => a.cmd === "agy")) d.agents = d.agents.filter((a) => a.cmd !== "gemini");
288
+ if (!runPick || runPick.k !== k)
289
+ runPick = { k, agent: d.agents.some((a) => a.cmd === d.default) ? d.default : d.agents[0].cmd };
290
+ box.innerHTML = d.agents
291
+ .map(
292
+ (a) =>
293
+ `<button type="button" class="agent" role="radio" aria-checked="${a.cmd === runPick.agent}" data-cmd="${esc(a.cmd)}"><svg><use href="#c-${esc(a.cmd)}"/></svg>${esc(a.name)}</button>`,
294
+ )
295
+ .join("");
296
+ const yb = li.querySelector("#run-yolo");
297
+ // Yolo only where the picked tool has a skip-permissions mode; Basic otherwise.
298
+ const sw = yb.querySelector(".chk");
299
+ const showYolo = () => {
300
+ const can = !!d.agents.find((a) => a.cmd === runPick.agent)?.yolo,
301
+ on = !!runPick.yolo;
302
+ yb.hidden = !can;
303
+ sw.classList.toggle("on", on);
304
+ sw.setAttribute("aria-checked", on);
305
+ };
306
+ box.querySelectorAll(".agent").forEach(
307
+ (b) =>
308
+ (b.onclick = () => {
309
+ runPick.agent = b.dataset.cmd;
310
+ box.querySelectorAll(".agent").forEach((x) => x.setAttribute("aria-checked", x === b));
311
+ showYolo();
312
+ }),
313
+ );
314
+ sw.onclick = () => {
315
+ runPick.yolo = !runPick.yolo;
316
+ showYolo();
317
+ };
318
+ showYolo();
319
+ box.onkeydown = sw.onkeydown = (e) => {
320
+ if (e.key === "Escape") {
321
+ store.mode = null;
322
+ render();
323
+ }
324
+ };
325
+ }
326
+
327
+ async function startRun(c, kind) {
328
+ store.mode = null;
329
+ askNotify();
330
+ const p = runPick && runPick.k === key(c) ? runPick : null;
331
+ runPick = null;
332
+ const yolo = !!(p && p.yolo && !$("run-yolo")?.hidden);
333
+ const r = await post("/run", { dir: c.state, text: c.text, kind, agent: p ? p.agent : undefined, yolo });
334
+ if (r.ok) {
335
+ toast((kind === "full" ? "Full" : "Lite") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
336
+ if (r.pty) store.wantTerm = key(c);
337
+ }
338
+ load();
339
+ }
340
+
341
+ export function render() {
342
+ // A closed form leaves no draft behind; an open one is reopened after a reload.
343
+ // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
344
+ if (store.mode && store.mode !== "new" && !store.S.cards.some((c) => key(c) === store.modeKey)) store.mode = null;
345
+ if (store.mode !== "run" && runPick) runPick.yolo = false;
346
+ if (!store.mode) undraft("f:");
347
+ try {
348
+ sessionStorage.setItem(
349
+ "kz-form",
350
+ JSON.stringify(["new", "edit", "reject"].includes(store.mode) ? [store.mode, store.modeKey] : null),
351
+ );
352
+ } catch (e) {}
353
+ // The board is rebuilt below; the card that had focus gets it back.
354
+ const fk = focusedKey();
355
+ // All projects: only the ones ticked in the filter, or every one when none is.
356
+ const only = store.all && store.shown.size && showable().some((p) => store.shown.has(p.dir));
357
+ const cs = store.S.cards.filter((c) => c.archived === archive && (!only || store.shown.has(c.state))),
358
+ b = $("board");
359
+ b.innerHTML = "";
360
+ const nArch = store.S.cards.filter((c) => c.archived).length;
361
+ const n = (s) => cs.filter((c) => c.status === s).length;
362
+ const strip = [];
363
+ // One chip per status on the board: its mark, its count, and a click filters to it. Running always shows.
364
+ for (const st of ["waiting", "running", "stalled", "starting", "idea", "done", "abandoned"]) {
365
+ const k = n(st);
366
+ if (!k && st !== "running") continue;
367
+ const mark =
368
+ st === "running"
369
+ ? `<span class="d-run"><span class="pulse"></span></span>`
370
+ : `<span class="st d-${ICON[st]}"><svg><use href="#s-${ICON[st]}"/></svg></span>`;
371
+ strip.push(
372
+ `<button class="${st === "waiting" ? "attn " : ""}${filter === st ? "on" : ""}" data-f="${st}" title="Show only ${WORD[st]}">${mark}${k} ${WORD[st]}${st === "idea" && k !== 1 ? "s" : ""}</button>`,
373
+ );
374
+ }
375
+ strip.push(
376
+ `<button class="${archive ? "on" : ""}" data-f="__archive" title="${cs.filter((c) => c.kind === "idea").length} ideas · ${cs.filter((c) => c.kind === "run").length} runs on the board">${archive ? "Back to board" : "Archive " + nArch}</button>`,
377
+ );
378
+ $("strip").innerHTML = strip.join("");
379
+ $("strip")
380
+ .querySelectorAll("button")
381
+ .forEach(
382
+ (c) =>
383
+ (c.onclick = () => {
384
+ if (c.dataset.f === "__archive") {
385
+ archive = !archive;
386
+ filter = null;
387
+ render();
388
+ return;
389
+ }
390
+ filter = filter === c.dataset.f ? null : c.dataset.f;
391
+ render();
392
+ }),
393
+ );
394
+ // [label, folder, from the projects folder]: the button names the current one, the open list is redrawn.
395
+ store.PL = [
396
+ ["All projects", "all"],
397
+ ...store.S.projects.map((p) => [p.label, p.dir]),
398
+ ...store.S.fresh.map((p) => [p.label, p.dir, 1]),
399
+ ["Find projects on this PC…", "__find"],
400
+ ];
401
+ const cur = store.all || !store.S.dir ? "All projects" : (store.PL.find((p) => p[1] === store.S.dir) || ["All projects"])[0];
402
+ $("proj").textContent = short(cur);
403
+ $("proj").title = cur;
404
+ if (!$("projpop").hidden) list();
405
+ $("showc").hidden = !store.all;
406
+ const sp = showable(),
407
+ ns = sp.filter((p) => store.shown.has(p.dir)).length;
408
+ $("show").textContent = ns ? `Showing ${ns} of ${sp.length}` : "Showing all";
409
+ if (!$("showpop").hidden) showList();
410
+ if (!cs.length && store.mode !== "new") {
411
+ b.innerHTML = archive
412
+ ? '<div class="empty"><div class="mascot"></div><b>Nothing archived</b></div>'
413
+ : '<div class="empty"><div class="mascot"></div><b>Nothing here yet</b><p>Add an idea and kaizen will plan it, build it, and check the result.</p><button class="btn pri" id="newempty">New idea</button></div>';
414
+ if (!archive)
415
+ $("newempty").onclick = () => {
416
+ store.mode = "new";
417
+ render();
418
+ };
419
+ }
420
+ COLS.forEach((name, ci) => {
421
+ const s = document.createElement("section");
422
+ const list = cs.filter((c) => c.column === ci && (!filter || c.status === filter));
423
+ // Runs age by their last move, ideas by inbox position (new ones are appended).
424
+ if (ci < 4) {
425
+ const at = (c) => (c.kind === "run" ? c.moved || 0 : store.S.cards.indexOf(c)),
426
+ old = order[ci] === "old";
427
+ list.sort((a, b) => (old ? at(a) - at(b) : at(b) - at(a)));
428
+ }
429
+ const clearable = ci === 4 && !archive && list.length;
430
+ const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
431
+ let head = `<h2>${name}<i>${list.length || ""}</i>${ci < 4 && list.length > 1 ? `<button class="btn sm ghost ord" title="Sort order">${order[ci] === "old" ? "Oldest" : "Newest"}</button>` : ""}${ci === 0 && !archive ? `<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>` : ""}`;
432
+ if (clearable && picking)
433
+ head += `<span class="pickbar"><button class="btn sm ghost" data-p="cancel">Cancel</button><button class="btn sm pri" data-p="go" ${picked.size ? "" : "disabled"}>Archive ${picked.size || ""}</button></span>`;
434
+ else if (clearable) head += `<button class="btn sm ghost clear" title="Archive finished runs">Clear ▾</button>`;
435
+ if (clearable && store.clearMenu)
436
+ head += `<div class="menu-pop clear-pop"><button data-c="7">Older than a week <i>${olderThan(7).length}</i></button><button data-c="3">Older than 3 days <i>${olderThan(3).length}</i></button><button data-c="0">Everything <i>${list.length}</i></button><hr><button data-c="pick">Pick which…</button></div>`;
437
+ s.innerHTML = head + `</h2>`;
438
+ {
439
+ const o = s.querySelector(".ord");
440
+ if (o)
441
+ o.onclick = (e) => {
442
+ e.stopPropagation();
443
+ order[ci] = order[ci] === "old" ? "new" : "old";
444
+ try {
445
+ localStorage.setItem("kz-order", JSON.stringify(order));
446
+ } catch (e) {}
447
+ render();
448
+ };
449
+ }
450
+ if (ci === 0 && !archive)
451
+ s.querySelector(".ib").onclick = () => {
452
+ store.mode = "new";
453
+ render();
454
+ };
455
+ if (clearable) {
456
+ const cl = s.querySelector(".clear");
457
+ if (cl)
458
+ cl.onclick = (e) => {
459
+ e.stopPropagation();
460
+ store.clearMenu = !store.clearMenu;
461
+ render();
462
+ };
463
+ s.querySelectorAll("[data-c]").forEach(
464
+ (bt) =>
465
+ (bt.onclick = async (e) => {
466
+ e.stopPropagation();
467
+ store.clearMenu = false;
468
+ const v = bt.dataset.c;
469
+ if (v === "pick") {
470
+ picking = true;
471
+ picked.clear();
472
+ render();
473
+ return;
474
+ }
475
+ const set = olderThan(+v);
476
+ if (!set.length) {
477
+ toast("Nothing that old");
478
+ render();
479
+ return;
480
+ }
481
+ for (const c of set) await post("/archive", { dir: c.state, id: c.id });
482
+ toast("Archived " + set.length);
483
+ load();
484
+ }),
485
+ );
486
+ s.querySelectorAll("[data-p]").forEach(
487
+ (bt) =>
488
+ (bt.onclick = async (e) => {
489
+ e.stopPropagation();
490
+ if (bt.dataset.p === "cancel") {
491
+ picking = false;
492
+ picked.clear();
493
+ render();
494
+ return;
495
+ }
496
+ const set = list.filter((c) => picked.has(key(c)));
497
+ picking = false;
498
+ picked.clear();
499
+ for (const c of set) await post("/archive", { dir: c.state, id: c.id });
500
+ toast("Archived " + set.length);
501
+ load();
502
+ }),
503
+ );
504
+ }
505
+ const ul = document.createElement("ul");
506
+ if (ci === 0 && store.mode === "new") {
507
+ const f = document.createElement("li");
508
+ f.className = "inl";
509
+ f.innerHTML = `${store.all ? `<div class="combo" id="ndc"><button class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<input id="newidea" placeholder="What would you like done?"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add idea</button></div>`;
510
+ ul.appendChild(f);
511
+ const inp = f.querySelector("#newidea");
512
+ draft(inp, "f:new:0");
513
+ draft(f.querySelector("textarea"), "f:new:1");
514
+ const save = async () => {
515
+ const text = inp.value.trim();
516
+ if (!text) return;
517
+ const notes = f.querySelector("textarea").value.trim();
518
+ store.mode = null;
519
+ const dir = store.all ? newDir : null;
520
+ const r = await post("/inbox", dir ? { op: "add", dir, text, notes } : { op: "add", text, notes });
521
+ if (r.ok) toast("Idea added");
522
+ load();
523
+ };
524
+ if (store.all) ideaCombo(f);
525
+ f.querySelector("#ns").onclick = save;
526
+ f.querySelector("#nc").onclick = () => {
527
+ store.mode = null;
528
+ render();
529
+ };
530
+ {
531
+ const m = mic([inp, f.querySelector("textarea")]);
532
+ if (m) f.querySelector(".row").prepend(m);
533
+ }
534
+ inp.onkeydown = (e) => {
535
+ if (e.key === "Enter") save();
536
+ if (e.key === "Escape") {
537
+ store.mode = null;
538
+ render();
539
+ }
540
+ };
541
+ setTimeout(() => inp.focus());
542
+ }
543
+ const shown = unfolded.has(ci) || list.length <= FOLD + 1 ? list : list.slice(0, FOLD);
544
+ shown.forEach((c) => {
545
+ if (store.modeKey === key(c) && store.mode && store.mode !== "new") {
546
+ ul.appendChild(inlineForm(c));
547
+ return;
548
+ }
549
+ const li = document.createElement("li");
550
+ li.appendChild(card(c));
551
+ ul.appendChild(li);
552
+ });
553
+ if (shown.length < list.length) {
554
+ const li = document.createElement("li");
555
+ li.innerHTML = `<button class="more-btn">Show ${list.length - shown.length} more</button>`;
556
+ li.firstChild.onclick = () => {
557
+ unfolded.add(ci);
558
+ render();
559
+ };
560
+ ul.appendChild(li);
561
+ } else if (unfolded.has(ci) && list.length > FOLD + 1) {
562
+ const li = document.createElement("li");
563
+ li.innerHTML = `<button class="more-btn">Show less</button>`;
564
+ li.firstChild.onclick = () => {
565
+ unfolded.delete(ci);
566
+ render();
567
+ };
568
+ ul.appendChild(li);
569
+ }
570
+ s.appendChild(ul);
571
+ b.appendChild(s);
572
+ });
573
+ stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
574
+ if (fk && document.activeElement === document.body) cardEl(fk)?.focus({ preventScroll: true });
575
+ }
576
+ export const focusedKey = () => document.activeElement?.closest?.("#board .card")?.dataset.k;
577
+ export const cardEl = (k) => k && $("board").querySelector('.card[data-k="' + CSS.escape(k) + '"]');
578
+ // Arrows and hjkl, as on the terminal board: up/down within a column, left/right to the
579
+ // nearest column with cards, at the same row or its last card.
580
+ export function nav(dx, dy) {
581
+ const cols = [...$("board").querySelectorAll("section")]
582
+ .map((s) => [...s.querySelectorAll(".card")])
583
+ .filter((l) => l.length);
584
+ if (!cols.length) return;
585
+ const cur = document.activeElement.closest(".card");
586
+ let ci = cols.findIndex((l) => l.includes(cur)),
587
+ ri = 0;
588
+ if (ci < 0) ci = 0;
589
+ else {
590
+ ri = cols[ci].indexOf(cur);
591
+ if (dy) ri = Math.max(0, Math.min(cols[ci].length - 1, ri + dy));
592
+ else {
593
+ ci = Math.max(0, Math.min(cols.length - 1, ci + dx));
594
+ ri = Math.min(ri, cols[ci].length - 1);
595
+ }
596
+ }
597
+ const t = cols[ci][ri];
598
+ t.focus({ preventScroll: true });
599
+ t.scrollIntoView({ block: "nearest", inline: "nearest" });
600
+ }