kaizen-agent 1.43.0 → 1.43.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/agents/kaizen-reviewer.md +1 -1
- package/cli/board.test.ts +3 -2
- package/cli/browser.test.ts +1 -1
- package/cli/browser.ts +12 -7
- package/cli/pty.ts +2 -1
- package/cli/settings.test.ts +38 -0
- package/cli/settings.ts +37 -30
- package/cli/state.test.ts +25 -1
- package/cli/state.ts +26 -0
- package/cli/web.ts +9 -1
- package/package.json +1 -1
- package/skills/kaizen/README.md +0 -4
- package/skills/kaizen/SKILL.md +4 -1
- package/skills/kaizen/config.default.yml +1 -50
- package/skills/kaizen/docs.md +3 -2
- package/skills/kaizen/settings.md +10 -3
- package/skills/kaizen/spec-build.md +3 -2
- package/skills/kaizen/spec-main.md +40 -5
- package/skills/kaizen/spec-review.md +2 -1
- package/skills/kaizen-help/SKILL.md +0 -4
- package/web/board/agent-term.ts +1 -1
- package/web/board/api.ts +2 -0
- package/web/board/cards.ts +50 -7
- package/web/board/git.ts +1 -1
- package/web/board/icebox.ts +1 -1
- package/web/board/keys.ts +2 -0
- package/web/board/live.ts +3 -2
- package/web/board/projects.ts +2 -2
- package/web/board/settings.ts +47 -12
- package/web/board/terms.ts +279 -0
- package/web/board/views.ts +19 -5
- package/web/board.html +28 -2
- package/web/board.js +25 -24
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
|
@@ -97,13 +97,48 @@ tab from staying empty; the reviewer flagging it later is a second net, not the
|
|
|
97
97
|
|
|
98
98
|
---
|
|
99
99
|
|
|
100
|
+
## Docs gate
|
|
101
|
+
|
|
102
|
+
Before a run can be `done` — in every runner and every mode, `auto` included — bring
|
|
103
|
+
the project's docs vault up to date with what the run changed. Apply it once the review
|
|
104
|
+
is settled and before the final approval report, so the user approves the notes with
|
|
105
|
+
the work; where the user's answer sends findings back to be fixed, apply it again over
|
|
106
|
+
those fixes before setting `stage: "done"`. In a run with a `worktree`, edit the notes
|
|
107
|
+
in the worktree, where the vault is committed with the code. Where
|
|
108
|
+
`notes/Project/Index.md` does not exist in the state directory, there is no vault and
|
|
109
|
+
nothing to do. The builder already updates the notes it can see during the build; this
|
|
110
|
+
is the step that catches what it could not: fix iterations after it, and changes no
|
|
111
|
+
note covers.
|
|
112
|
+
|
|
113
|
+
1. **What changed:** every path named in `03-impl.md` and in each
|
|
114
|
+
`05-iterations/*-fix.md`, leaving out `.kaizen/` itself.
|
|
115
|
+
2. **Covered:** for each note whose `sources` include one of those paths (a folder in
|
|
116
|
+
`sources` covers everything under it), check the note against the changed files.
|
|
117
|
+
Where the change made it untrue or left something out, correct it as *Keep the
|
|
118
|
+
project's docs vault current* in [`spec-build.md`](spec-build.md) says — never the
|
|
119
|
+
`## Notes` section — set `updated` to today, and append the `Log.md` line, unless the
|
|
120
|
+
builder already wrote this run's line, in which case add the notes to it.
|
|
121
|
+
3. **Not covered:** a changed path no note's `sources` reach. Add it to the `sources`
|
|
122
|
+
of the note whose area it belongs to and describe it there; where it belongs to no
|
|
123
|
+
area, write a note for it as step 4 of *Update* in [`docs.md`](docs.md) says and
|
|
124
|
+
list it in `Index.md`. A path that is not part of the project's material — a lock
|
|
125
|
+
file, a generated bundle — needs no note.
|
|
126
|
+
4. **Lint** the notes touched, as *Lint* in [`docs.md`](docs.md) lists, and fix what
|
|
127
|
+
this run broke.
|
|
128
|
+
|
|
129
|
+
Name the notes touched in the final report; the commit at done includes them. A run
|
|
130
|
+
whose changes leave every note true touches nothing and says so in one line.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
100
134
|
## Final approval
|
|
101
135
|
|
|
102
136
|
Controlled by `approvals.review`, skipped in `auto`.
|
|
103
137
|
|
|
104
138
|
Report: what was built (for a code run, show its Preview proof), verification results, findings fixed, findings still open,
|
|
105
139
|
every criterion the reviewer answered `CANNOT VERIFY` with the check it says a human
|
|
106
|
-
must make,
|
|
140
|
+
must make, anything the builder noted as out of scope, and the notes the *Docs gate*
|
|
141
|
+
touched. Where the run used `runner: lite`, say
|
|
107
142
|
so in that report — the reviewer saw the work, and nobody should read a same-session
|
|
108
143
|
review as an independent one because the output looked the same.
|
|
109
144
|
Later build requests in the same session follow *Follow-ups are runs too* in [`spec.md`](spec.md): a revise of this run until it is `done`, then a new linked run.
|
|
@@ -133,10 +168,10 @@ Set `stage: "done"` in `state.json` once the user accepts.
|
|
|
133
168
|
|
|
134
169
|
**Commit at done, when `git.auto_commit` is `true`.** Setting `stage: "done"` — the
|
|
135
170
|
user approving at review, or an `auto` run finishing — is then the ask to commit, under
|
|
136
|
-
either runner. Commit only the files this run changed (the paths `03-impl.md` names
|
|
137
|
-
that `git status` shows changed), by path:
|
|
138
|
-
`git commit -m <message> -- <paths>`, never the whole
|
|
139
|
-
the same checkout. The message is `03-impl.md`'s `## Commit`. In a run with a
|
|
171
|
+
either runner. Commit only the files this run changed (the paths `03-impl.md` names
|
|
172
|
+
and the notes the *Docs gate* touched, that `git status` shows changed), by path:
|
|
173
|
+
`git add -A -- <paths>` then `git commit -m <message> -- <paths>`, never the whole
|
|
174
|
+
tree, since other sessions edit the same checkout. The message is `03-impl.md`'s `## Commit`. In a run with a
|
|
140
175
|
`worktree`, commit there. Then write `runs/<id>/07-commit.md` as `commit: <short sha>`
|
|
141
176
|
and `pushed: no`, so the board shows it committed and offers the push. Never push. A
|
|
142
177
|
failed commit is reported as printed; the run is still `done`. With the setting
|
|
@@ -125,7 +125,8 @@ and no criterion is `CANNOT VERIFY`; otherwise
|
|
|
125
125
|
`; <n> cannot verify` when any criterion was — even when every count is 0, so an open
|
|
126
126
|
human check never reads as a clean pass.
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
Where `review.write_memory` is true (the default), the reviewer also appends any
|
|
129
|
+
durable lesson about this material to `memory.md` —
|
|
129
130
|
a recurring bug pattern, a non-obvious constraint, a convention worth keeping. Not
|
|
130
131
|
run-specific detail; only what the next run would want to know.
|
|
131
132
|
|
|
@@ -88,15 +88,11 @@ Each iteration is written to `runs/<id>/05-iterations/`.
|
|
|
88
88
|
| `auto_fix.enabled` | `true` | Auto-fix findings vs. escalate everything |
|
|
89
89
|
| `auto_fix.min_severity` | `high` | Threshold for auto-fix (`critical\|high\|medium\|low`) |
|
|
90
90
|
| `auto_fix.max_iterations` | `2` | Fix/recheck rounds before escalating remaining findings |
|
|
91
|
-
| `track.default` | `auto` (inferred per run) | Pin a track for a repo that is always one kind of work |
|
|
92
91
|
| `review.write_memory` | `true` | Let the reviewer append lessons to `.kaizen/memory.md` |
|
|
93
92
|
| `backlog.enabled` | `true` | Capture what a run deliberately did not do |
|
|
94
|
-
| `backlog.file` | `06-backlog.md` | Per-run backlog file |
|
|
95
|
-
| `backlog.orphan_file` | `backlog.md` | Where a pruned run's open items are rescued to |
|
|
96
93
|
| `state.dir` | `.kaizen` | State directory |
|
|
97
94
|
| `state.keep_runs` | `20` | Completed runs kept before pruning oldest (rescues open backlog first) |
|
|
98
95
|
| `git.auto_commit` | `false` | `true`: commit the run's changed files when it is done; never pushes |
|
|
99
|
-
| `git.branch_before_implement` | `true` | Branch before implement stage when on the default branch |
|
|
100
96
|
| `subagents.model` | `inherit` | Model the stage subagents run on (opus/sonnet/haiku, Claude Code only) |
|
|
101
97
|
| `subagents.effort` | `inherit` | Reasoning effort for the stage subagents (low/medium/high, Claude Code only) |
|
|
102
98
|
| `sources.every` | `24h` | How often the web board checks request sources for new ideas (`off\|1h\|6h\|24h`) |
|
package/web/board/agent-term.ts
CHANGED
|
@@ -11,7 +11,7 @@ let termKey = null,
|
|
|
11
11
|
termOff = null,
|
|
12
12
|
termLib = null;
|
|
13
13
|
export const where = (r) => (r.pty ? "running in its card" : "opened in your terminal");
|
|
14
|
-
const termLoad = () =>
|
|
14
|
+
export const termLoad = () =>
|
|
15
15
|
(termLib ||= new Promise((ok, no) => {
|
|
16
16
|
const l = document.createElement("link");
|
|
17
17
|
l.rel = "stylesheet";
|
package/web/board/api.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { key, render } from "./cards.ts";
|
|
|
3
3
|
import { $ } from "./dom.ts";
|
|
4
4
|
import { close, ideaBody, open, openRun } from "./panel.ts";
|
|
5
5
|
import { notifyUser, toast } from "./toast.ts";
|
|
6
|
+
import { openTerms, termsShowing } from "./terms.ts";
|
|
6
7
|
|
|
7
8
|
// The server writes notifier() from state.ts over a marker in board.html: one rule for both UIs.
|
|
8
9
|
declare const due: (key: string, say: string | null, done: boolean, now: number) => boolean;
|
|
@@ -36,6 +37,7 @@ export async function load(quiet) {
|
|
|
36
37
|
}
|
|
37
38
|
// A redraw under a form being typed in would reset its fields and its focus.
|
|
38
39
|
if (!(quiet && store.mode && $("board").querySelector(".inl input"))) render();
|
|
40
|
+
if (termsShowing()) openTerms();
|
|
39
41
|
// Run on an idea closed the panel: open the card on its agent once the board knows the session.
|
|
40
42
|
if (store.wantTerm && !store.openKey && !store.mode) {
|
|
41
43
|
const n = store.S.cards.find((x) => key(x) === store.wantTerm && x.pty);
|
package/web/board/cards.ts
CHANGED
|
@@ -237,6 +237,7 @@ function card(c) {
|
|
|
237
237
|
} else {
|
|
238
238
|
items.push(["open", actionOf(c), ico("open")]);
|
|
239
239
|
items.push(["archive", "Archive", ico("archive")]);
|
|
240
|
+
if (c.column === 1 && c.status !== "abandoned") items.push(["toidea", "Back to idea", ico("restore")]);
|
|
240
241
|
if (c.status === "waiting" || c.status === "running" || c.status === "stalled") {
|
|
241
242
|
items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
|
|
242
243
|
}
|
|
@@ -267,6 +268,12 @@ function card(c) {
|
|
|
267
268
|
toggleArchive(c, a === "archive");
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
271
|
+
if (a === "toidea") {
|
|
272
|
+
const r = await post("/to-idea", { dir: c.state, id: c.id });
|
|
273
|
+
if (r.ok) toast("Back in Ideas");
|
|
274
|
+
load();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
270
277
|
store.mode = a;
|
|
271
278
|
store.modeKey = k;
|
|
272
279
|
render();
|
|
@@ -302,7 +309,7 @@ function inlineForm(c) {
|
|
|
302
309
|
li.className = "inl";
|
|
303
310
|
const dir = c.state;
|
|
304
311
|
if (store.mode === "edit") {
|
|
305
|
-
li.innerHTML = html`<input value="${c.text}"><textarea placeholder="
|
|
312
|
+
li.innerHTML = html`<input value="${c.text}"><textarea placeholder="Anything the planner should know">${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>`;
|
|
306
313
|
li.querySelector("[data-a=s]").onclick = async () => {
|
|
307
314
|
const next = li.querySelector("input").value.trim(),
|
|
308
315
|
notes = li.querySelector("textarea").value.trim();
|
|
@@ -314,7 +321,7 @@ function inlineForm(c) {
|
|
|
314
321
|
}
|
|
315
322
|
if (store.mode === "reject") {
|
|
316
323
|
const idea = c.kind !== "run";
|
|
317
|
-
li.innerHTML = html`<div class="q">${c.kind === "backlog" ? "Not doing this backlog item?" : idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="
|
|
324
|
+
li.innerHTML = html`<div class="q">${c.kind === "backlog" ? "Not doing this backlog item?" : idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Why not?"><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>`;
|
|
318
325
|
li.querySelector("[data-a=s]").onclick = async () => {
|
|
319
326
|
const why = li.querySelector("input").value.trim();
|
|
320
327
|
if (!why) {
|
|
@@ -450,7 +457,7 @@ async function startRun(c, kind) {
|
|
|
450
457
|
const backlog = c.kind === "backlog" ? { source: c.source, n: c.n } : undefined;
|
|
451
458
|
const r = await post("/run", { dir: c.state, text: c.text, backlog, kind, agent: p ? p.agent : undefined, yolo });
|
|
452
459
|
if (r.ok) {
|
|
453
|
-
toast((kind === "full" ? "Full" : "Lite") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
|
|
460
|
+
toast((kind === "full" ? "Full" : kind === "lite" ? "Lite" : "Started") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
|
|
454
461
|
// A backlog card has no terminal of its own; the run's card gets it once the run exists.
|
|
455
462
|
if (r.pty && !backlog) store.wantTerm = key(c);
|
|
456
463
|
}
|
|
@@ -461,12 +468,13 @@ const HINT = { 1: "Ideas you run are planned here", 2: "Approved plans are built
|
|
|
461
468
|
|
|
462
469
|
// The new-idea form, wired into f: inline at the top of Ideas, or in a modal.
|
|
463
470
|
function ideaForm(f, close) {
|
|
464
|
-
f.innerHTML = html`${store.all ? html`<div class="combo" id="ndc"><button type="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
|
|
471
|
+
f.innerHTML = html`${store.all ? html`<div class="combo" id="ndc"><button type="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 do you want to build?"><textarea id="newnotes" placeholder="Anything the planner should know (optional)"></textarea><div class="att" id="newatt"></div><input type="file" id="newfile" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" id="nc">Cancel</button><div class="split"><button type="button" class="btn pri" id="ns">Add</button><button type="button" class="btn pri" id="nm" aria-label="More ways to add" aria-haspopup="menu" aria-expanded="false">${ico("chev")}</button><div class="menu-pop" role="menu" hidden><button type="button" role="menuitem" id="nr" title="Add the idea and run it now">${ico("play")}Start run</button></div></div></div>`;
|
|
465
472
|
const inp = f.querySelector("#newidea"),
|
|
466
473
|
files = [];
|
|
467
474
|
draft(inp, "f:new:0");
|
|
468
475
|
draft(f.querySelector("textarea"), "f:new:1");
|
|
469
|
-
|
|
476
|
+
// run: add the idea, then launch it at once with the project's runner and agent.
|
|
477
|
+
const save = async (run) => {
|
|
470
478
|
const text = inp.value.trim();
|
|
471
479
|
if (!text) return;
|
|
472
480
|
const notes = f.querySelector("textarea").value.trim();
|
|
@@ -481,6 +489,11 @@ function ideaForm(f, close) {
|
|
|
481
489
|
const n = await post("/note", { dir, for: text, text: "", files: enc });
|
|
482
490
|
if (!n.ok) toast("Idea added, files not attached");
|
|
483
491
|
}
|
|
492
|
+
if (run) {
|
|
493
|
+
await load();
|
|
494
|
+
const c = store.S.cards.find((x) => x.kind === "idea" && x.text === text && (!dir || x.state === dir));
|
|
495
|
+
if (c) return startRun(c);
|
|
496
|
+
}
|
|
484
497
|
}
|
|
485
498
|
load();
|
|
486
499
|
};
|
|
@@ -503,7 +516,37 @@ function ideaForm(f, close) {
|
|
|
503
516
|
}
|
|
504
517
|
};
|
|
505
518
|
if (store.all) ideaCombo(f);
|
|
506
|
-
f.querySelector("#ns").onclick = save;
|
|
519
|
+
f.querySelector("#ns").onclick = () => save(false);
|
|
520
|
+
f.querySelector("#nr").onclick = () => save(true);
|
|
521
|
+
{
|
|
522
|
+
const mb = f.querySelector("#nm"),
|
|
523
|
+
menu = f.querySelector(".split .menu-pop");
|
|
524
|
+
// Focus moves into the menu only when it was opened from the keyboard.
|
|
525
|
+
const show = (on, kbd) => {
|
|
526
|
+
menu.hidden = !on;
|
|
527
|
+
mb.setAttribute("aria-expanded", String(on));
|
|
528
|
+
if (on && kbd) setTimeout(() => menu.querySelector("button").focus());
|
|
529
|
+
};
|
|
530
|
+
mb.onclick = (e) => {
|
|
531
|
+
e.stopPropagation();
|
|
532
|
+
show(menu.hidden, e.detail === 0);
|
|
533
|
+
};
|
|
534
|
+
menu.onkeydown = (e) => {
|
|
535
|
+
if (e.key === "Escape") {
|
|
536
|
+
e.preventDefault();
|
|
537
|
+
e.stopPropagation();
|
|
538
|
+
show(false);
|
|
539
|
+
mb.focus();
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
f.addEventListener("click", (e) => {
|
|
543
|
+
if (!menu.hidden && !e.target.closest(".split")) show(false);
|
|
544
|
+
});
|
|
545
|
+
const sp = f.querySelector(".split");
|
|
546
|
+
sp.addEventListener("focusout", (e) => {
|
|
547
|
+
if (!sp.contains(e.relatedTarget)) show(false);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
507
550
|
f.querySelector("#nc").onclick = close;
|
|
508
551
|
{
|
|
509
552
|
const m = mic([inp, f.querySelector("textarea")]);
|
|
@@ -512,7 +555,7 @@ function ideaForm(f, close) {
|
|
|
512
555
|
inp.onkeydown = (e) => {
|
|
513
556
|
if (e.key === "Enter") {
|
|
514
557
|
e.preventDefault();
|
|
515
|
-
save();
|
|
558
|
+
save(false);
|
|
516
559
|
}
|
|
517
560
|
if (e.key === "Escape") {
|
|
518
561
|
e.preventDefault();
|
package/web/board/git.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function gitBtn(c) {
|
|
|
31
31
|
export function confirmCommit(c, msg) {
|
|
32
32
|
const g = store.runDoc.git,
|
|
33
33
|
foot = $("pfoot");
|
|
34
|
-
foot.innerHTML = html`<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length === 1 ? "" : "s"} on ${g.branch || "this branch"} and push to ${g.remote}?</div><div class="q dim">Only the files this run's report names.${g.others ? ` ${g.others} other changed file${g.others === 1 ? " is" : "s are"} left alone.` : ""}</div><pre class="files">${g.files.join("\n")}</pre>${g.shared && g.shared.length ? html`<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${g.shared.join("\n")}</pre>` : ""}<textarea aria-label="Commit message" placeholder="
|
|
34
|
+
foot.innerHTML = html`<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length === 1 ? "" : "s"} on ${g.branch || "this branch"} and push to ${g.remote}?</div><div class="q dim">Only the files this run's report names.${g.others ? ` ${g.others} other changed file${g.others === 1 ? " is" : "s are"} left alone.` : ""}</div><pre class="files">${g.files.join("\n")}</pre>${g.shared && g.shared.length ? html`<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${g.shared.join("\n")}</pre>` : ""}<textarea aria-label="Commit message" placeholder="Describe what changed">${msg || commitMsg(store.runDoc.impl) || c.title || c.text}</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">Commit & push</button></div></div>`;
|
|
35
35
|
const inp = foot.querySelector("textarea"),
|
|
36
36
|
go = foot.querySelector("[data-a=s]");
|
|
37
37
|
inp.focus();
|
package/web/board/icebox.ts
CHANGED
|
@@ -975,7 +975,7 @@ export async function openSources() {
|
|
|
975
975
|
if (!dir && !showable().length) return toast("Pick a project first");
|
|
976
976
|
// The new-idea form, cut down: the request, files, dictation and, in All projects, the project.
|
|
977
977
|
const dl = modal(
|
|
978
|
-
`${all ? `<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this request" 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>` : ""}<textarea id="newidea" placeholder="
|
|
978
|
+
`${all ? `<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this request" 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>` : ""}<textarea id="newidea" placeholder="What did someone ask for?" aria-label="Request"></textarea><div class="att"></div><input type="file" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" value="">Cancel</button><button class="btn pri">Add</button></div>`,
|
|
979
979
|
);
|
|
980
980
|
const f = dl.querySelector("form"),
|
|
981
981
|
inp = f.querySelector("textarea"),
|
package/web/board/keys.ts
CHANGED
|
@@ -54,6 +54,8 @@ export function initKeys() {
|
|
|
54
54
|
} else close();
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
|
+
// The Terminal page has no cards to move between; its keys are the terminals'.
|
|
58
|
+
if (view === "terminal") return;
|
|
57
59
|
if (e.key === "n" && !document.querySelector("dialog[open]")) {
|
|
58
60
|
if (view !== "board") go("board", true);
|
|
59
61
|
newIdea();
|
package/web/board/live.ts
CHANGED
|
@@ -34,9 +34,10 @@ const v0 = ((v) => (v === "icebox" ? "sources" : v))(new URLSearchParams(locatio
|
|
|
34
34
|
export function initLive() {
|
|
35
35
|
// Back from a terminal: what was committed or pushed there touched nothing the server watches.
|
|
36
36
|
addEventListener("focus", () => load(true));
|
|
37
|
-
markView(["settings", "sources"].includes(v0) ? v0 : "board");
|
|
38
|
-
document.body.classList.toggle("paged", ["settings", "sources"].includes(v0));
|
|
37
|
+
markView(["settings", "sources", "terminal"].includes(v0) ? v0 : "board");
|
|
38
|
+
document.body.classList.toggle("paged", ["settings", "sources", "terminal"].includes(v0));
|
|
39
39
|
document.body.classList.toggle("ice", v0 === "sources");
|
|
40
|
+
document.body.classList.toggle("termv", v0 === "terminal");
|
|
40
41
|
(async () => {
|
|
41
42
|
let d;
|
|
42
43
|
try {
|
package/web/board/projects.ts
CHANGED
|
@@ -57,7 +57,7 @@ async function pick(v) {
|
|
|
57
57
|
} else if (v === "__new") {
|
|
58
58
|
// A name, and where it goes: the projects folder unless typed or picked.
|
|
59
59
|
const dl = modal(
|
|
60
|
-
`<b>New project</b><input name="name" placeholder="Project name" aria-label="Project name" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="
|
|
60
|
+
`<b>New project</b><input name="name" placeholder="Project name" aria-label="Project name" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Folder to create" aria-label="Create in" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Create</button></div>`,
|
|
61
61
|
);
|
|
62
62
|
const f = dl.querySelector("form"),
|
|
63
63
|
name = f.querySelector("[name=name]"),
|
|
@@ -96,7 +96,7 @@ async function pick(v) {
|
|
|
96
96
|
} else if (v === "__clone") {
|
|
97
97
|
// One form: the link, then where it goes. The folder fills in once the link names a repo.
|
|
98
98
|
const dl = modal(
|
|
99
|
-
`<b>Clone from GitHub</b><input name="url" placeholder="https://github.com/owner/repo
|
|
99
|
+
`<b>Clone from GitHub</b><input name="url" placeholder="https://github.com/owner/repo" aria-label="GitHub link (https or ssh)" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Folder to clone into" aria-label="Clone into" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Clone</button></div>`,
|
|
100
100
|
);
|
|
101
101
|
const f = dl.querySelector("form"),
|
|
102
102
|
url = f.querySelector("[name=url]"),
|
package/web/board/settings.ts
CHANGED
|
@@ -47,7 +47,8 @@ export async function openSettings() {
|
|
|
47
47
|
$("psub").textContent = "";
|
|
48
48
|
$("psub").title = "";
|
|
49
49
|
// One control per row: a switch for on/off, one segment per value for a small number,
|
|
50
|
-
//
|
|
50
|
+
// ‹ value › for a choice, stepping and wrapping like a game's options screen. The row's
|
|
51
|
+
// label names the control; its help sits under it.
|
|
51
52
|
const ctl = (id, vals, on) => {
|
|
52
53
|
// The preset is a segmented control over the tiers; custom, which only an edit to another row makes, lights none.
|
|
53
54
|
if (id === "s-preset")
|
|
@@ -60,10 +61,12 @@ export async function openSettings() {
|
|
|
60
61
|
}
|
|
61
62
|
if (vals.every((v) => /^\d+$/.test(v)))
|
|
62
63
|
return html`<span class="bar" id="${id}" role="group">${vals.map((v) => html`<button data-v="${v}" class="${v === on ? "on" : ""}" aria-pressed="${String(v === on)}">${v}</button>`)}</span>`;
|
|
63
|
-
|
|
64
|
+
// Wired by stepper() below; the group takes its name from the row's label.
|
|
65
|
+
return html`<span class="step" id="${id}" role="group" aria-labelledby="${id}-l"><button id="${id}-prev" aria-label="Previous">‹</button><output aria-live="polite">${on === "" ? "—" : on}</output><button id="${id}-next" aria-label="Next">›</button></span>`;
|
|
64
66
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// `off`: the row has no effect while another setting stands as it is; dimmed, still editable.
|
|
68
|
+
const row = (label, control, hint, id, q, off = false) =>
|
|
69
|
+
html`<div class="srow${off ? " off" : ""}" data-q="${q.toLowerCase()}"><label for="${id}" id="${id}-l">${label}</label>${control}${hint ? html`<p class="hint">${hint}</p>` : ""}</div>`;
|
|
67
70
|
const hadFocus = document.activeElement?.id === "sq";
|
|
68
71
|
const np = typeof Notification === "undefined" ? null : Notification.permission;
|
|
69
72
|
const ntHelp = np ? (np === "denied" ? "Blocked by this browser" : "") : "Not supported in this browser";
|
|
@@ -83,16 +86,17 @@ export async function openSettings() {
|
|
|
83
86
|
? html`<span class="fpick"><input class="sel" id="${id}" value="${r.value}" placeholder="not set" spellcheck="false"><button class="btn" id="${id}-pick" type="button">Browse…</button></span>`
|
|
84
87
|
: html`<input class="sel" id="${id}" value="${r.value}" placeholder="not set" spellcheck="false">`
|
|
85
88
|
: ctl(id, r.values, r.value),
|
|
86
|
-
r.help,
|
|
89
|
+
[r.help, r.idle].filter(Boolean).join(" · "),
|
|
87
90
|
id,
|
|
88
91
|
[r.label, r.help, r.key, g, ...r.values].join(" "),
|
|
92
|
+
!!r.idle,
|
|
89
93
|
);
|
|
90
94
|
})}</div>`,
|
|
91
95
|
)
|
|
92
96
|
: html`<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`
|
|
93
97
|
}
|
|
94
|
-
<h3 class="sg">Google</h3><div class="sgrp">${row("OAuth client id", html`<input class="sel" id="gg-id" placeholder="${gg.hasClient ? "
|
|
95
|
-
${row("OAuth client secret", html`<span class="fpick"><input class="sel" id="gg-secret" type="password" placeholder="${gg.hasClient ? "
|
|
98
|
+
<h3 class="sg">Google</h3><div class="sgrp">${row("OAuth client id", html`<input class="sel" id="gg-id" placeholder="${gg.hasClient ? "Saved. Paste a new one to replace it" : "Desktop app client id from Google Cloud"}" spellcheck="false" autocomplete="off">`, "", "gg-id", "google oauth client id sheets write back")}
|
|
99
|
+
${row("OAuth client secret", html`<span class="fpick"><input class="sel" id="gg-secret" type="password" placeholder="${gg.hasClient ? "Saved. Paste a new one to replace it" : ""}" spellcheck="false" autocomplete="off"><button class="btn" id="gg-save" type="button">Save</button></span>`, "", "gg-secret", "google oauth client secret")}
|
|
96
100
|
${row("Google account", html`<button class="btn${gg.connected ? "" : " pri"}" id="gg-conn" type="button"${gg.hasClient || gg.connected ? "" : " disabled"}>${gg.connected ? "Disconnect" : "Connect"}</button>`, "", "gg-conn", "google account connect sheets write back")}</div>
|
|
97
101
|
<h3 class="sg">Browser</h3><div class="sgrp">${row("Theme", ctl("th", ["system", "light", "dark"], cur), "", "th", "theme light dark system browser")}
|
|
98
102
|
${row("Notifications", html`<button class="chk${np === "granted" ? " on" : ""}" id="nt" role="switch" aria-checked="${String(np === "granted")}"${np && np !== "denied" ? "" : " disabled"}></button>`, ntHelp, "nt", "notifications " + ntHelp + " browser")}</div>
|
|
@@ -154,7 +158,32 @@ export async function openSettings() {
|
|
|
154
158
|
toast(j.error || "Could not start Google sign-in");
|
|
155
159
|
}
|
|
156
160
|
};
|
|
157
|
-
|
|
161
|
+
// A stepper keeps its own position, so each click moves one value even before a save
|
|
162
|
+
// re-renders the panel; the shown value changes at once and apply() gets the new one.
|
|
163
|
+
// ←/→ press its arrows; focus moves to the arrow pressed.
|
|
164
|
+
const stepper = (el, vals, on, apply) => {
|
|
165
|
+
let i = Math.max(vals.indexOf(on), 0);
|
|
166
|
+
const step = (d) => {
|
|
167
|
+
i = (i + d + vals.length) % vals.length;
|
|
168
|
+
el.querySelector("output").textContent = vals[i] === "" ? "—" : vals[i];
|
|
169
|
+
apply(vals[i]);
|
|
170
|
+
};
|
|
171
|
+
$(el.id + "-prev").onclick = () => step(-1);
|
|
172
|
+
$(el.id + "-next").onclick = () => step(1);
|
|
173
|
+
el.onkeydown = (e) => {
|
|
174
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
const b = $(el.id + (e.key === "ArrowLeft" ? "-prev" : "-next"));
|
|
177
|
+
b.focus();
|
|
178
|
+
b.click();
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
// Re-rendering replaces the arrows, so the one pressed gets focus back.
|
|
182
|
+
const refocus = async (f) => {
|
|
183
|
+
await openSettings();
|
|
184
|
+
if (f && /^s-preset-|-(prev|next)$/.test(f)) $(f)?.focus();
|
|
185
|
+
};
|
|
186
|
+
stepper($("th"), ["system", "light", "dark"], cur, setTheme);
|
|
158
187
|
$("nt").onclick = () => {
|
|
159
188
|
askNotify();
|
|
160
189
|
setTimeout(openSettings, 300);
|
|
@@ -166,16 +195,22 @@ export async function openSettings() {
|
|
|
166
195
|
const f = document.activeElement?.id;
|
|
167
196
|
const j = await post("/settings", { key: r.key, value: v });
|
|
168
197
|
if (j.ok) toast("Saved");
|
|
169
|
-
await
|
|
170
|
-
if (f?.startsWith("s-preset-")) $(f)?.focus();
|
|
198
|
+
await refocus(f);
|
|
171
199
|
};
|
|
172
|
-
if (el.tagName === "
|
|
200
|
+
if (el.tagName === "INPUT")
|
|
173
201
|
el.onchange = async () => {
|
|
174
202
|
await save(el.value.trim());
|
|
175
|
-
|
|
203
|
+
load();
|
|
176
204
|
};
|
|
177
205
|
else el.querySelectorAll("[data-v]").forEach((b) => (b.onclick = () => save(b.dataset.v)));
|
|
178
206
|
if (el.matches(".chk")) el.onclick = () => save(el.dataset.v);
|
|
207
|
+
// Clicks in quick succession save once, the value they settle on.
|
|
208
|
+
let t;
|
|
209
|
+
if (el.matches(".step"))
|
|
210
|
+
stepper(el, r.values, r.value, (v) => {
|
|
211
|
+
clearTimeout(t);
|
|
212
|
+
t = setTimeout(() => save(v), 300);
|
|
213
|
+
});
|
|
179
214
|
// The system's own folder dialog, opened by the board: a page cannot read a real path.
|
|
180
215
|
if (r.folder)
|
|
181
216
|
$(el.id + "-pick").onclick = async () => {
|