kaizen-agent 1.40.3 → 1.41.2
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/install.ts +2 -1
- package/cli/setup.test.ts +2 -1
- package/cli/sources.test.ts +20 -1
- package/cli/sources.ts +20 -0
- package/cli/state.test.ts +16 -0
- package/cli/state.ts +6 -2
- package/cli/web.ts +39 -1
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +3 -1
- package/web/board/cards.ts +9 -15
- package/web/board/dom.ts +23 -0
- package/web/board/icebox.ts +57 -4
- package/web/board/projects.ts +47 -2
- package/web/board.html +1 -0
- package/web/board.js +19 -19
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/install.ts
CHANGED
|
@@ -576,7 +576,8 @@ if (upgrade) {
|
|
|
576
576
|
// is not a failed upgrade: the version on disk is what decides.
|
|
577
577
|
if (after !== latest) {
|
|
578
578
|
console.log(`\n ${c.bold(`could not install kaizen ${latest}`)}`);
|
|
579
|
-
|
|
579
|
+
// npm names a new version before it serves it (its review takes minutes).
|
|
580
|
+
console.log(` ${c.dim("npm may still be publishing it: try again in a few minutes, or run")} ${c.cyan(`bunx kaizen-agent@${latest}`)}\n`);
|
|
580
581
|
process.exit(1);
|
|
581
582
|
}
|
|
582
583
|
console.log(`\n ${c.bold("Upgraded")} ${c.dim(now)} → ${c.green(after)}\n ${c.dim("Restart Claude Code to pick up any new slash commands.")}\n`);
|
package/cli/setup.test.ts
CHANGED
|
@@ -38,7 +38,8 @@ test("kaizen setup without a terminal prints the manual steps and writes nothing
|
|
|
38
38
|
const out = await new Response(p.stdout).text();
|
|
39
39
|
expect(await p.exited).toBe(0);
|
|
40
40
|
expect(out).toContain("tailscale serve --bg 127.0.0.1:7420");
|
|
41
|
-
|
|
41
|
+
// Bun keeps its own cache in HOME (~/.bun); only what kaizen writes counts.
|
|
42
|
+
expect(readdirSync(homeDir).filter((n) => n !== ".bun")).toEqual([]);
|
|
42
43
|
expect(existsSync(join(homeDir, ".kaizen"))).toBe(false);
|
|
43
44
|
} finally { rmSync(homeDir, { recursive: true, force: true }); }
|
|
44
45
|
});
|
package/cli/sources.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { readInbox, writeInbox, replaceIdea, parseItem } from "./state.ts";
|
|
|
8
8
|
import {
|
|
9
9
|
readSources, addSource, removeSource, ingest, checkUrl, resolvesPublic, exportUrl, fetchText, split, sift,
|
|
10
10
|
gatherSource, freshImages, gatherAll, due, takeImages, sheetImages, docImages, imagesUrl, SIGN_IN, UNREADABLE, CAP, moveSource, gatherDirs, writeSources,
|
|
11
|
-
readIcebox, writeIcebox, promote, markDone, dismiss, markReplied, setCategory, socialUrl, socialOf, AGENT_ONLY, PLATFORMS,
|
|
11
|
+
readIcebox, writeIcebox, promote, markDone, addItem, githubRepo, dismiss, markReplied, setCategory, socialUrl, socialOf, AGENT_ONLY, PLATFORMS,
|
|
12
12
|
} from "./sources.ts";
|
|
13
13
|
|
|
14
14
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-src-"))); // macOS: tmpdir is a symlink into /private
|
|
@@ -821,3 +821,22 @@ test("a done run that only mentions the idea does not finish it", () => {
|
|
|
821
821
|
markDone(state);
|
|
822
822
|
expect(readIcebox(state)[0]).toMatchObject({ status: "done", run: "r1" });
|
|
823
823
|
});
|
|
824
|
+
|
|
825
|
+
test("icebox: an item added by hand lands new, once, and promotes like a gathered one", () => {
|
|
826
|
+
expect(addItem(state, " ")).toBe("write the request first");
|
|
827
|
+
expect(addItem(state, "Dark mode for the board", "asked twice")).toBeNull();
|
|
828
|
+
expect(addItem(state, "Dark mode for the board", "asked twice")).toBe("already in the icebox");
|
|
829
|
+
const [it] = open();
|
|
830
|
+
expect(it).toMatchObject({ text: "Dark mode for the board", notes: "asked twice", source: "manual", status: "new" });
|
|
831
|
+
expect(promote(state, [it.id])).toBeNull();
|
|
832
|
+
expect(readInbox(state).map((l) => l.text)).toEqual(["Dark mode for the board"]);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("githubRepo: https and ssh GitHub links only, named by the repository", () => {
|
|
836
|
+
expect(githubRepo("https://github.com/acme/app")).toBe("app");
|
|
837
|
+
expect(githubRepo("https://github.com/acme/app.git")).toBe("app");
|
|
838
|
+
expect(githubRepo("git@github.com:acme/my.repo.git")).toBe("my.repo");
|
|
839
|
+
expect(githubRepo("ssh://git@github.com/acme/app.git")).toBe("app");
|
|
840
|
+
for (const bad of ["http://github.com/a/b", "https://gitlab.com/a/b", "https://github.com/a", "git@github.com:a/..", "--upload-pack=x", "https://github.com/a/b;rm -rf ~"])
|
|
841
|
+
expect(githubRepo(bad)).toBeNull();
|
|
842
|
+
});
|
package/cli/sources.ts
CHANGED
|
@@ -237,6 +237,26 @@ export function ingest(state: string, url: string, items: Item[]): { added: numb
|
|
|
237
237
|
return { added: fresh.length, held };
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// A request typed into the icebox by hand: kept like a gathered one, source "manual".
|
|
241
|
+
// Returns why not, or null.
|
|
242
|
+
export function addItem(state: string, text: string, notes = ""): string | null {
|
|
243
|
+
const c = clean(text, notes);
|
|
244
|
+
if (!c) return "write the request first";
|
|
245
|
+
const ice = readIcebox(state);
|
|
246
|
+
const n = c.lines.join("\n").slice(0, 2000), key = rowKey(c.text, n);
|
|
247
|
+
if (ice.some((s) => rowKey(s.text, s.notes) === key)) return "already in the icebox";
|
|
248
|
+
ice.push({ id: idOf("manual " + key), text: c.text, ...(n ? { notes: n } : {}), source: "manual", at: new Date().toISOString(), status: "new" });
|
|
249
|
+
writeIcebox(state, ice);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// A GitHub repository link git can clone: https://github.com/o/r(.git) or
|
|
254
|
+
// git@github.com:o/r(.git). Returns the folder name it clones into, or null.
|
|
255
|
+
export function githubRepo(raw: string): string | null {
|
|
256
|
+
const m = raw.trim().match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)[\w.-]+\/([\w.-]+?)(?:\.git)?\/?$/);
|
|
257
|
+
return m && m[1] !== "." && m[1] !== ".." ? m[1] : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
240
260
|
// The only way icebox text becomes an idea. Several items may become one idea (the
|
|
241
261
|
// same need said twice); the idea's text is the caller's, or the first item's, and
|
|
242
262
|
// `details` are the caller's own words for it. The notes hold those details first,
|
package/cli/state.test.ts
CHANGED
|
@@ -93,6 +93,22 @@ test("boardCards: a started idea stays until its run has a state.json", () => {
|
|
|
93
93
|
rmSync(join(dir, ".."), { recursive: true, force: true });
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test("boardCards: a started idea retires into the run its launch's session wrote, however reworded", () => {
|
|
97
|
+
const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
|
|
98
|
+
const r = join(dir, "runs", "2026-01-01-reworded");
|
|
99
|
+
mkdirSync(r, { recursive: true });
|
|
100
|
+
mkdirSync(join(dir, "sessions"));
|
|
101
|
+
writeInbox(dir, [{ status: "started", text: "revision" }]);
|
|
102
|
+
writeFileSync(join(r, "00-request.md"), "- add a form for the icebox\n");
|
|
103
|
+
writeFileSync(join(dir, "sessions", "1-a.json"), JSON.stringify({ run: null, text: "revision", cmd: "claude", sessionId: "abc", started: Date.now() }));
|
|
104
|
+
const cards = () => boardCards([dir], Date.now()).map((k) => k.kind);
|
|
105
|
+
writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-reworded", stage: "build", awaiting: null }));
|
|
106
|
+
expect(cards()).toEqual(["idea", "run"]);
|
|
107
|
+
writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-reworded", stage: "build", awaiting: null, session: "abc" }));
|
|
108
|
+
expect(cards()).toEqual(["run"]);
|
|
109
|
+
rmSync(join(dir, ".."), { recursive: true, force: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
96
112
|
test("boardCards: a run older than the idea does not retire it", () => {
|
|
97
113
|
const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
|
|
98
114
|
const r = join(dir, "runs", "2026-01-01-old");
|
package/cli/state.ts
CHANGED
|
@@ -616,6 +616,7 @@ export type Card = {
|
|
|
616
616
|
awaiting: string | null;
|
|
617
617
|
dim: boolean;
|
|
618
618
|
moved?: number; // runs only: state.json mtime
|
|
619
|
+
added?: number; // ideas only: when the inbox line first appeared (ms)
|
|
619
620
|
title?: string; // runs only: first line of the request
|
|
620
621
|
archived: boolean; // hidden from the board unless asked for
|
|
621
622
|
branch?: string; // runs only: the run's own branch while it has a worktree
|
|
@@ -1001,6 +1002,9 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
1001
1002
|
for (const l of b.split("\n")) lines.push({ at, text: normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")) });
|
|
1002
1003
|
}
|
|
1003
1004
|
}
|
|
1005
|
+
// A launched idea whose run reworded the request: the board's own launch record,
|
|
1006
|
+
// owned by a run (session id in its state.json, see ownerOf), retires it all the same.
|
|
1007
|
+
const launchedInto = (st: string, t: string) => readSessions(st).some((s) => !s.run && normalise(s.text.replace(/^(lite|full|auto|plan)\s+/i, "")) === t.replace(/^(lite|full|auto|plan)\s+/i, "") && ownerOf(st, s) !== null);
|
|
1004
1008
|
for (const st of states) {
|
|
1005
1009
|
const where = label(st);
|
|
1006
1010
|
const archive = readArchive(st);
|
|
@@ -1019,10 +1023,10 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
1019
1023
|
// titled "fix this bugs" must not swallow a new idea with the same words.
|
|
1020
1024
|
// ponytail: 2s slack for coarse filesystem clocks.
|
|
1021
1025
|
const after = (r: { at: number }) => !it.added || r.at >= it.added - 2000;
|
|
1022
|
-
if (it.status === "started" ? requests.some((r) => after(r) && r.text.includes(t)) : lines.some((l) => after(l) && (l.text === t || l.text.endsWith(" " + t)))) continue;
|
|
1026
|
+
if (it.status === "started" ? requests.some((r) => after(r) && r.text.includes(t)) || launchedInto(st, t) : lines.some((l) => after(l) && (l.text === t || l.text.endsWith(" " + t)))) continue;
|
|
1023
1027
|
next.push(it.status === "started"
|
|
1024
1028
|
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes, parent: parentOf(it.notes) }
|
|
1025
|
-
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes, parent: parentOf(it.notes) });
|
|
1029
|
+
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes, parent: parentOf(it.notes), added: it.added });
|
|
1026
1030
|
}
|
|
1027
1031
|
// Runs started in Yolo. Only yolo records are matched to runs, so a project that
|
|
1028
1032
|
// never used it pays one directory read.
|
package/cli/web.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession, webHosts,
|
|
11
11
|
} from "./state.ts";
|
|
12
12
|
import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
|
|
13
|
-
import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
13
|
+
import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, addItem, githubRepo, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
14
14
|
import { projectLogo, LOGO_TYPES } from "./logo.ts";
|
|
15
15
|
import * as chatgpt from "./chatgpt.ts";
|
|
16
16
|
import * as google from "./google.ts";
|
|
@@ -688,6 +688,34 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
688
688
|
return json({ ok: true, added: knownProjects().length - before });
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
+
// Clone a GitHub repository into the projects folder (else ~/Projects) and
|
|
692
|
+
// set it up as a project. Arguments go to git as an array: no shell.
|
|
693
|
+
if (path === "/clone") {
|
|
694
|
+
const link = typeof body.url === "string" ? body.url.trim() : "";
|
|
695
|
+
const name = githubRepo(link);
|
|
696
|
+
if (!name) return bad("paste a GitHub link: https://github.com/owner/repo or git@github.com:owner/repo.git");
|
|
697
|
+
const root = (await projectsRoot()) || join(home, "Projects");
|
|
698
|
+
// The page may name the folder: typed (~ allowed, relative to home) or picked.
|
|
699
|
+
// A new one is made, parents too; an empty one is used; anything else refused.
|
|
700
|
+
let to = join(root, name);
|
|
701
|
+
if (typeof body.to === "string" && body.to.trim()) {
|
|
702
|
+
const t = body.to.trim();
|
|
703
|
+
to = resolve(home, nativePath(t.startsWith("~") ? home + t.slice(1) : t));
|
|
704
|
+
}
|
|
705
|
+
if (body.check) return json({ ok: true, to, name });
|
|
706
|
+
let used = false;
|
|
707
|
+
try { used = readdirSync(to).length > 0; } catch (e) { used = existsSync(to); }
|
|
708
|
+
if (used) return bad(`${to} already exists and is not empty`, 409);
|
|
709
|
+
mkdirSync(to, { recursive: true });
|
|
710
|
+
const p = Bun.spawn(["git", "clone", "--", link, to], { stdin: "ignore", stdout: "ignore", stderr: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes" } });
|
|
711
|
+
const [err, code] = await Promise.all([new Response(p.stderr).text(), p.exited]);
|
|
712
|
+
if (code !== 0) return bad(err.trim().split("\n").filter(Boolean).pop() || "git clone failed", 500);
|
|
713
|
+
mkdirSync(join(to, ".kaizen"), { recursive: true });
|
|
714
|
+
remember(to);
|
|
715
|
+
changed();
|
|
716
|
+
return json({ ok: true, dir: join(to, ".kaizen") });
|
|
717
|
+
}
|
|
718
|
+
|
|
691
719
|
// No project here means the global inbox, which is where the TUI puts ideas too.
|
|
692
720
|
const dir: string = body.dir ?? state ?? join(home, ".kaizen");
|
|
693
721
|
// Choosing a folder from the projects folder is what sets it up: an empty
|
|
@@ -787,6 +815,15 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
787
815
|
} catch (e) { return bad((e as Error).message, 500); }
|
|
788
816
|
}
|
|
789
817
|
|
|
818
|
+
// Files added with a hand-written icebox item: saved beside the state, linked in its notes.
|
|
819
|
+
const iceFiles = (state: string, files: unknown) => {
|
|
820
|
+
const fs: { name: string; data: string }[] = Array.isArray(files) ? files : [];
|
|
821
|
+
if (fs.reduce((t, f) => t + (f.data?.length ?? 0), 0) > 20_000_000) throw new Error("attachments over 15MB");
|
|
822
|
+
return fs.map((f) => {
|
|
823
|
+
const rel = saveAttachment(state, null, String(f.name ?? "file"), Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64"));
|
|
824
|
+
return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel})`;
|
|
825
|
+
});
|
|
826
|
+
};
|
|
790
827
|
// The icebox: the only way gathered text becomes an idea, one explicit click
|
|
791
828
|
// (or agent call) at a time. An idea is made; no run starts.
|
|
792
829
|
if (path === "/icebox") {
|
|
@@ -794,6 +831,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
794
831
|
const ids = Array.isArray(body.ids) ? body.ids.filter((i: unknown) => typeof i === "string") : [];
|
|
795
832
|
try {
|
|
796
833
|
const why = body.op === "promote" ? promote(body.dir, ids, typeof body.text === "string" ? body.text : undefined, typeof body.details === "string" ? body.details : undefined)
|
|
834
|
+
: body.op === "add" ? addItem(body.dir, typeof body.text === "string" ? body.text : "", [typeof body.details === "string" ? body.details : "", ...iceFiles(body.dir, body.files)].filter(Boolean).join("\n"))
|
|
797
835
|
: body.op === "dismiss" ? dismiss(body.dir, ids) : body.op === "restore" ? restore(body.dir, ids) : "unknown op";
|
|
798
836
|
if (why) return bad(why, why === "no such item" ? 404 : why === "unknown op" ? 400 : 409);
|
|
799
837
|
} catch (e) { return bad((e as Error).message, 500); }
|
package/package.json
CHANGED
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -341,7 +341,9 @@ changing because a setting moved:
|
|
|
341
341
|
```
|
|
342
342
|
|
|
343
343
|
Every run writes all nine keys, from intake on: `id`, `mode`, `runner`, `agent`,
|
|
344
|
-
`track`, `stage`, `awaiting`, `iteration` (0 until the fix loop runs), `updated`.
|
|
344
|
+
`track`, `stage`, `awaiting`, `iteration` (0 until the fix loop runs), `updated`. Where
|
|
345
|
+
the environment has `CLAUDE_CODE_SESSION_ID`, also `session` with its value: without it
|
|
346
|
+
a card launched from the board can stay stuck in planning beside its own run. The
|
|
345
347
|
legal values of `stage` and `awaiting` are in [`spec.md`](spec.md).
|
|
346
348
|
|
|
347
349
|
In a git repository, `init` also adds a gitignore entry that ignores run state but
|
package/web/board/cards.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
|
|
|
2
2
|
import { where } from "./agent-term.ts";
|
|
3
3
|
import { load, post } from "./api.ts";
|
|
4
4
|
import { mic, stopDictation } from "./dictation.ts";
|
|
5
|
-
import { $, esc, ico } from "./dom.ts";
|
|
5
|
+
import { $, dataUrls, esc, ico } from "./dom.ts";
|
|
6
6
|
import { draft, undraft } from "./drafts.ts";
|
|
7
7
|
import { COLS, ICON, MARKS, NAMES, WORD, age, d, need } from "./labels.ts";
|
|
8
8
|
import { brief } from "./markdown.ts";
|
|
@@ -468,6 +468,7 @@ export function render() {
|
|
|
468
468
|
...store.S.projects.map((p) => [p.label, p.dir]),
|
|
469
469
|
...store.S.fresh.map((p) => [p.label, p.dir, 1]),
|
|
470
470
|
["Find projects on this PC…", "__find"],
|
|
471
|
+
["Clone from GitHub…", "__clone"],
|
|
471
472
|
];
|
|
472
473
|
const cur = store.all || !store.S.dir ? "All projects" : (store.PL.find((p) => p[1] === store.S.dir) || ["All projects"])[0];
|
|
473
474
|
$("proj").innerHTML =
|
|
@@ -501,13 +502,15 @@ export function render() {
|
|
|
501
502
|
(archive || !nested(c)) &&
|
|
502
503
|
(!q || `${c.title || ""} ${c.text || ""} ${c.id || ""}`.toLowerCase().includes(q)),
|
|
503
504
|
);
|
|
504
|
-
// Runs age by their last move, ideas by
|
|
505
|
+
// Runs age by their last move, ideas by when they were added, across every project;
|
|
506
|
+
// an idea from before the stamp falls back to its inbox position.
|
|
505
507
|
if (ci < 4) {
|
|
506
|
-
const at = (c) => (c.kind === "run" ? c.moved || 0 :
|
|
507
|
-
old = order[ci] === "old"
|
|
508
|
+
const at = (c) => (c.kind === "run" ? c.moved || 0 : c.added || 0),
|
|
509
|
+
old = order[ci] === "old",
|
|
510
|
+
pos = (c) => store.S.cards.indexOf(c);
|
|
508
511
|
// Runs waiting on you go first, then the column's own order.
|
|
509
512
|
const w = (c) => (c.status === "waiting" ? 1 : 0);
|
|
510
|
-
list.sort((a, b) => w(b) - w(a) || (old ? at(a) - at(b) : at(b) - at(a)));
|
|
513
|
+
list.sort((a, b) => w(b) - w(a) || (old ? at(a) - at(b) || pos(a) - pos(b) : at(b) - at(a) || pos(b) - pos(a)));
|
|
511
514
|
}
|
|
512
515
|
const clearable = ci === 4 && !archive && list.length;
|
|
513
516
|
const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
|
|
@@ -606,16 +609,7 @@ export function render() {
|
|
|
606
609
|
toast("Idea added");
|
|
607
610
|
// The idea exists first: a failed upload leaves it as plain text.
|
|
608
611
|
if (files.length) {
|
|
609
|
-
const enc = await
|
|
610
|
-
files.map(
|
|
611
|
-
(x) =>
|
|
612
|
-
new Promise((res) => {
|
|
613
|
-
const rd = new FileReader();
|
|
614
|
-
rd.onload = () => res({ name: x.name, data: rd.result });
|
|
615
|
-
rd.readAsDataURL(x);
|
|
616
|
-
}),
|
|
617
|
-
),
|
|
618
|
-
);
|
|
612
|
+
const enc = await dataUrls(files);
|
|
619
613
|
const n = await post("/note", { dir, for: text, text: "", files: enc });
|
|
620
614
|
if (!n.ok) toast("Idea added, files not attached");
|
|
621
615
|
}
|
package/web/board/dom.ts
CHANGED
|
@@ -9,3 +9,26 @@ export const SKEL =
|
|
|
9
9
|
'<span class="skel" style="height:16px;width:35%;margin:20px 0 14px"></span>' +
|
|
10
10
|
'<span class="skel" style="height:44px;margin-bottom:10px"></span>'.repeat(4) +
|
|
11
11
|
"</div>";
|
|
12
|
+
// Files as {name, data-URL}, the shape /note and /icebox take.
|
|
13
|
+
export const dataUrls = (files: File[]) =>
|
|
14
|
+
Promise.all(
|
|
15
|
+
files.map(
|
|
16
|
+
(x) =>
|
|
17
|
+
new Promise<{ name: string; data: string }>((res) => {
|
|
18
|
+
const rd = new FileReader();
|
|
19
|
+
rd.onload = () => res({ name: x.name, data: rd.result as string });
|
|
20
|
+
rd.readAsDataURL(x);
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
// A modal form: the native <dialog>, removed from the page once closed.
|
|
25
|
+
export function modal(html: string) {
|
|
26
|
+
const dl = document.createElement("dialog");
|
|
27
|
+
dl.className = "kz";
|
|
28
|
+
dl.innerHTML = `<form class="inl" method="dialog">${html}</form>`;
|
|
29
|
+
dl.onclose = () => dl.remove();
|
|
30
|
+
dl.onclick = (e) => e.target === dl && dl.close(); // a tap on the backdrop
|
|
31
|
+
document.body.appendChild(dl);
|
|
32
|
+
dl.showModal();
|
|
33
|
+
return dl;
|
|
34
|
+
}
|
package/web/board/icebox.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { store } from "./store.ts";
|
|
|
2
2
|
import { termStop } from "./agent-term.ts";
|
|
3
3
|
import { load, post } from "./api.ts";
|
|
4
4
|
import { via } from "./cards.ts";
|
|
5
|
-
import { stopDictation } from "./dictation.ts";
|
|
6
|
-
import { $, esc, ico, SKEL } from "./dom.ts";
|
|
7
|
-
import { plogo, setShown, short, showable } from "./projects.ts";
|
|
5
|
+
import { mic, stopDictation } from "./dictation.ts";
|
|
6
|
+
import { $, dataUrls, esc, ico, modal, SKEL } from "./dom.ts";
|
|
7
|
+
import { ideaCombo, newDir, plogo, setShown, short, showable } from "./projects.ts";
|
|
8
8
|
import { toast } from "./toast.ts";
|
|
9
9
|
import { go, view } from "./views.ts";
|
|
10
10
|
|
|
@@ -340,7 +340,7 @@ export async function openSources() {
|
|
|
340
340
|
.join(""),
|
|
341
341
|
)
|
|
342
342
|
: "");
|
|
343
|
-
const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${iceCat === "Dismissed" || iceCat === "Done" ? `${order.length} ${iceCat.toLowerCase()}` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span><span class="end"></span>${chips ? `<details class="ice-filt"><summary class="ib${iceCat || iceSrc ? " on" : ""}" aria-label="Filter requests" title="Filter requests">${ico("filter")}</summary><div class="ice-fpop">${chips}</div></details>` : ""}<button class="btn sm" id="src-go"${list.length ? "" : " disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow ?? !list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
|
|
343
|
+
const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${iceCat === "Dismissed" || iceCat === "Done" ? `${order.length} ${iceCat.toLowerCase()}` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span><span class="end"></span>${chips ? `<details class="ice-filt"><summary class="ib${iceCat || iceSrc ? " on" : ""}" aria-label="Filter requests" title="Filter requests">${ico("filter")}</summary><div class="ice-fpop">${chips}</div></details>` : ""}<button class="btn ghost sm" id="ice-new" title="Add a request by hand">${ico("plus")} Add</button><button class="btn sm" id="src-go"${list.length ? "" : " disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow ?? !list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
|
|
344
344
|
const bulk = iceSel.size
|
|
345
345
|
? `<div class="ice-bulk" role="toolbar" aria-label="Selected requests"><span>${iceSel.size} selected</span><span class="end"></span><button class="btn sm" id="ice-bp" title="Promote (p)">${ico("plus")} Promote</button><button class="btn ghost sm" id="ice-bd" title="Dismiss (d)">${ico("x")} Dismiss</button><button class="btn ghost sm" id="ice-bc" title="Clear selection (Esc)">Clear</button></div>`
|
|
346
346
|
: "";
|
|
@@ -987,6 +987,59 @@ export async function openSources() {
|
|
|
987
987
|
if (e.key === "Enter") $("src-add").click();
|
|
988
988
|
};
|
|
989
989
|
}
|
|
990
|
+
if ($("ice-new"))
|
|
991
|
+
$("ice-new").onclick = () => {
|
|
992
|
+
const all = !dir;
|
|
993
|
+
if (!dir && !showable().length) return toast("Pick a project first");
|
|
994
|
+
// The new-idea form, cut down: the request, files, dictation and, in All projects, the project.
|
|
995
|
+
const dl = modal(
|
|
996
|
+
`${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="New request for the icebox" 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>`,
|
|
997
|
+
);
|
|
998
|
+
const f = dl.querySelector("form"),
|
|
999
|
+
inp = f.querySelector("textarea"),
|
|
1000
|
+
files = [];
|
|
1001
|
+
const add = (x) => {
|
|
1002
|
+
files.push(x);
|
|
1003
|
+
f.querySelector(".att").textContent = files.map((y) => y.name).join(" · ");
|
|
1004
|
+
};
|
|
1005
|
+
f.querySelector("#newpick").onclick = () => f.querySelector("input[type=file]").click();
|
|
1006
|
+
f.querySelector("input[type=file]").onchange = (e) => {
|
|
1007
|
+
[...e.target.files].forEach(add);
|
|
1008
|
+
e.target.value = "";
|
|
1009
|
+
};
|
|
1010
|
+
inp.onpaste = (e) => {
|
|
1011
|
+
for (const it of e.clipboardData.items)
|
|
1012
|
+
if (it.kind === "file") {
|
|
1013
|
+
const x = it.getAsFile();
|
|
1014
|
+
add(new File([x], x.name === "image.png" ? `paste-${Date.now()}.png` : x.name, { type: x.type }));
|
|
1015
|
+
e.preventDefault();
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
f.querySelector(".btn.ghost").onclick = () => dl.close();
|
|
1019
|
+
if (all) ideaCombo(f);
|
|
1020
|
+
const m = mic([inp]);
|
|
1021
|
+
if (m) f.querySelector(".row").prepend(m);
|
|
1022
|
+
dl.addEventListener("close", () => stopDictation(false));
|
|
1023
|
+
inp.onkeydown = (e) => {
|
|
1024
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1025
|
+
e.preventDefault();
|
|
1026
|
+
f.requestSubmit();
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
f.onsubmit = async (e) => {
|
|
1030
|
+
e.preventDefault();
|
|
1031
|
+
const text = inp.value.trim(),
|
|
1032
|
+
to = dir || newDir;
|
|
1033
|
+
if (!text) return inp.focus();
|
|
1034
|
+
if (!to) return toast("Pick a project first");
|
|
1035
|
+
const j = await post("/icebox", { op: "add", dir: to, text, files: await dataUrls(files) });
|
|
1036
|
+
if (j.ok) {
|
|
1037
|
+
toast("Added to the icebox");
|
|
1038
|
+
dl.close();
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
inp.focus();
|
|
1042
|
+
};
|
|
990
1043
|
if ($("src-go"))
|
|
991
1044
|
$("src-go").onclick = () => {
|
|
992
1045
|
$("src-go").disabled = true;
|
package/web/board/projects.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { store } from "./store.ts";
|
|
2
2
|
import { load, post } from "./api.ts";
|
|
3
3
|
import { render } from "./cards.ts";
|
|
4
|
-
import { $, esc, ico } from "./dom.ts";
|
|
4
|
+
import { $, esc, ico, modal } from "./dom.ts";
|
|
5
5
|
import { openSources, srcOpen } from "./icebox.ts";
|
|
6
6
|
import { toast } from "./toast.ts";
|
|
7
7
|
import { view } from "./views.ts";
|
|
@@ -35,7 +35,7 @@ export function list() {
|
|
|
35
35
|
store.PL.map((p, i) =>
|
|
36
36
|
p[0].toLowerCase().includes(q)
|
|
37
37
|
? (p[2] && !g++ ? `<div class="grp">In your projects folder</div>` : "") +
|
|
38
|
-
`<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__find" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
|
|
38
|
+
`<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
|
|
39
39
|
: "",
|
|
40
40
|
).join("") || `<div class="grp">No match</div>`;
|
|
41
41
|
const f = $("projs").querySelector("button");
|
|
@@ -55,6 +55,51 @@ async function pick(v) {
|
|
|
55
55
|
toast("Searching…");
|
|
56
56
|
const j = await post("/find", {});
|
|
57
57
|
if (j.ok) toast(j.added ? `Found ${j.added} new project${j.added === 1 ? "" : "s"}` : "No new projects found");
|
|
58
|
+
} else if (v === "__clone") {
|
|
59
|
+
// One form: the link, then where it goes. The folder fills in once the link names a repo.
|
|
60
|
+
const dl = modal(
|
|
61
|
+
`<b>Clone from GitHub</b><input name="url" placeholder="https://github.com/owner/repo or git@github.com:owner/repo" aria-label="GitHub link (https or ssh)" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Clone into (a new folder is made)" 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>`,
|
|
62
|
+
);
|
|
63
|
+
const f = dl.querySelector("form"),
|
|
64
|
+
url = f.querySelector("[name=url]"),
|
|
65
|
+
to = f.querySelector("[name=to]");
|
|
66
|
+
let name = "",
|
|
67
|
+
auto = ""; // the folder this form filled in; one the user typed is left alone
|
|
68
|
+
const check = async () => {
|
|
69
|
+
const u = url.value.trim();
|
|
70
|
+
if (!u) return null;
|
|
71
|
+
const d = await post("/clone", { url: u, check: true });
|
|
72
|
+
if (!d.ok) return null;
|
|
73
|
+
name = d.name;
|
|
74
|
+
if (!to.value.trim() || to.value === auto) to.value = auto = d.to;
|
|
75
|
+
return d;
|
|
76
|
+
};
|
|
77
|
+
url.onchange = check;
|
|
78
|
+
f.querySelector("[data-a=x]").onclick = () => dl.close();
|
|
79
|
+
f.querySelector("[data-a=pick]").onclick = async () => {
|
|
80
|
+
if (!(await check())) return url.focus();
|
|
81
|
+
const p = await post("/pick-folder", {});
|
|
82
|
+
if (p.path) to.value = p.path.replace(/[\\/]+$/, "") + (p.path.includes("\\") ? "\\" : "/") + name;
|
|
83
|
+
};
|
|
84
|
+
url.focus();
|
|
85
|
+
const dir = await new Promise((done) => {
|
|
86
|
+
dl.addEventListener("close", () => done(null));
|
|
87
|
+
f.onsubmit = async (e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
if (!(await check())) return url.focus();
|
|
90
|
+
toast("Cloning…");
|
|
91
|
+
const j = await post("/clone", { url: url.value.trim(), to: to.value.trim() || undefined });
|
|
92
|
+
if (!j.ok) return;
|
|
93
|
+
done(j.dir);
|
|
94
|
+
dl.close();
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
if (!dir) return;
|
|
98
|
+
await post("/cd", { dir });
|
|
99
|
+
store.all = false;
|
|
100
|
+
store.dir = dir;
|
|
101
|
+
keep(dir);
|
|
102
|
+
toast("Cloned");
|
|
58
103
|
} else if (v === "all") {
|
|
59
104
|
store.all = true;
|
|
60
105
|
keep();
|
package/web/board.html
CHANGED
|
@@ -219,6 +219,7 @@ section li.hint{display:block;padding:14px;border:1px dashed var(--line);border-
|
|
|
219
219
|
.inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
|
|
220
220
|
.inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end;align-items:center}
|
|
221
221
|
.inl .row .mic{margin-right:0}.inl .row #newpick{margin-right:auto}.inl .row .btn{white-space:nowrap}.inl .att{font-size:12px;color:var(--dim)}.inl .att{font-size:12px;color:var(--dim)}.inl .att:empty{display:none}
|
|
222
|
+
dialog.kz{border:0;padding:0;background:none;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:visible;color:var(--ink)}dialog.kz::backdrop{background:rgb(0 0 0/.45)}dialog.kz .row input{flex:1;min-width:0}dialog.kz #ndc{align-self:flex-start}
|
|
222
223
|
.inl .run-actions{display:grid;gap:8px}.inl .run-actions .row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inl .run-actions .pri{grid-column:1/-1}
|
|
223
224
|
.notes{display:flex;flex-direction:column;gap:8px}
|
|
224
225
|
#pbody img{max-width:100%;border-radius:var(--r-s)}
|