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 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
- console.log(` ${c.dim("try:")} ${c.cyan(`bunx kaizen-agent@${latest}`)}\n`);
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
- expect(readdirSync(homeDir)).toEqual([]);
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
  });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.40.3",
3
+ "version": "1.41.2",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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`. The
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
@@ -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 inbox position (new ones are appended).
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 : store.S.cards.indexOf(c)),
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 Promise.all(
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
+ }
@@ -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;
@@ -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)}