kaizen-agent 1.43.2 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/board.test.ts CHANGED
@@ -254,3 +254,46 @@ test("a capitalised phrase mid-sentence is prose, not a label", () => {
254
254
  test("criteria not numbered from 1 keep their own numbers", () => {
255
255
  expect(brief("X Goal: g. Criteria: (2) two. (3) three.").parts[1].body).toBe("- (2) two.\n- (3) three.");
256
256
  });
257
+
258
+ // pretty(): a one-line idea note splits into one paragraph per label.
259
+ test("pretty splits a labelled plain line in a note by its labels", async () => {
260
+ const { pretty } = await import("../web/board/chat.ts");
261
+ const t = "Parent: run-x add a timezone. OPEN QUESTION (waiting on Johan): recalc old rows? Context: PR #452.";
262
+ expect(pretty(t)).toBe("**Parent:** run-x add a timezone.\n\n**OPEN QUESTION (waiting on Johan):** recalc old rows?\n\n**Context:** PR #452.");
263
+ expect(pretty("just a note")).toBe("just a note");
264
+ expect(pretty("Goal: a\nScope: b")).toBe("Goal: a\nScope: b");
265
+ expect(pretty("Parent: x\ndo y. NOTE: a. Context: b.")).toBe("Parent: x\n\ndo y.\n\n**NOTE:** a.\n\n**Context:** b.");
266
+ expect(pretty("- Goal: a. Scope: b.")).toBe("- Goal: a. Scope: b.");
267
+ });
268
+
269
+ test("the note editor turns its DOM back into the markdown md() rendered it from", async () => {
270
+ const { toMd } = await import("../web/board/rich.ts");
271
+ // A minimal DOM: element nodes from md()'s tag soup, enough for toMd.
272
+ const parse = (h) => {
273
+ const root = { nodeType: 1, nodeName: "DIV", childNodes: [], getAttribute: () => null };
274
+ const stack = [root];
275
+ for (const m of h.matchAll(/<(\/?)(\w+)([^>]*)>|([^<]+)/g)) {
276
+ const top = stack[stack.length - 1];
277
+ if (m[4]) top.childNodes.push({ nodeType: 3, nodeName: "#text", textContent: m[4].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&") });
278
+ else if (m[1]) stack.pop();
279
+ else {
280
+ const attrs = Object.fromEntries([...m[3].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
281
+ const el = { nodeType: 1, nodeName: m[2].toUpperCase(), childNodes: [], getAttribute: (k) => attrs[k] ?? null };
282
+ Object.defineProperty(el, "textContent", { get: () => el.childNodes.map((c) => c.textContent).join("") });
283
+ top.childNodes.push(el);
284
+ if (!/^(img|br)$/.test(m[2])) stack.push(el);
285
+ }
286
+ }
287
+ return root;
288
+ };
289
+ const src = "# Title\n\nsome **bold**, *em* and `code` here \nnext\n\n- one\n- two\n\n1. first\n2. second\n\n![shot](/f/a.png) [link](http://x)";
290
+ expect(toMd(parse(md(src)))).toBe(src);
291
+ // A fresh line typed straight into the box, then one Chrome wrapped in a div.
292
+ expect(toMd(parse("plain <b>b</b>​<div>next line</div>"))).toBe("plain **b**\n\nnext line");
293
+ });
294
+
295
+ test("md() renders italic and two-space hard breaks, and leaves globs in code alone", () => {
296
+ expect(md("an *em* word")).toBe("<p>an <i>em</i> word</p>");
297
+ expect(md("`*.ts` and `*.md`, a * b * c")).toBe("<p><code>*.ts</code> and <code>*.md</code>, a * b * c</p>");
298
+ expect(md("one \ntwo\nthree")).toBe("<p>one<br>two three</p>");
299
+ });
package/cli/install.ts CHANGED
@@ -96,15 +96,17 @@ if (Bun.argv[2] === "browser") {
96
96
  process.exit(0);
97
97
  }
98
98
 
99
- // `kaizen card add "<text>" [--parent <run id>]`: what a run files as new work, linked
100
- // to the run it came from.
99
+ // `kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]`: what a run
100
+ // files as new work, linked to the run it came from.
101
101
  if (Bun.argv[2] === "card") {
102
102
  const { locate, label, addCard } = await import("./state.ts");
103
103
  const state = locate() ?? join(home, ".kaizen");
104
104
  const args = Bun.argv.slice(4), at = args.indexOf("--parent");
105
105
  const parent = at >= 0 ? args.splice(at, 2)[1] : undefined;
106
- if (Bun.argv[3] !== "add" || !args.length || (at >= 0 && !parent)) { console.log(`\n kaizen card add "<text>" [--parent <run id>]\n`); process.exit(1); }
107
- const r = addCard(state, args.join(" "), parent);
106
+ const nt = args.indexOf("--notes");
107
+ const details = nt >= 0 ? args.splice(nt, 2)[1] : undefined;
108
+ if (Bun.argv[3] !== "add" || !args.length || (at >= 0 && !parent) || (nt >= 0 && !details)) { console.log(`\n kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]\n`); process.exit(1); }
109
+ const r = addCard(state, args.join(" "), parent, details);
108
110
  if (typeof r === "string") { console.log(`\n ${r}\n`); process.exit(1); }
109
111
  console.log(`\n ${c.green("added")} ${args.join(" ")}\n ${c.dim(`parent ${r.parent} · project ${label(state)}`)}\n`);
110
112
  process.exit(0);
package/cli/pty.test.ts CHANGED
@@ -5,7 +5,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, realpa
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { launchMode, closeSessions, readSessions, sweepSessions, CLOSE_GRACE_MS } from "./state.ts";
8
- import { loginEnv, startPty, writePty, attachPty, ptyState } from "./pty.ts";
8
+ import { loginEnv, startPty, writePty, attachPty, ptyState, exitPty } from "./pty.ts";
9
9
  import { conptyHost, innerCommand, resizeSeq } from "./conpty.ts";
10
10
 
11
11
  const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-pty-")));
@@ -198,6 +198,18 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
198
198
  expect(await gone(looked)).toBe(true);
199
199
  });
200
200
 
201
+ test.skipIf(process.platform === "win32")("exitPty types /exit, and ends an agent that ignores it", async () => {
202
+ const wait = async (k: string) => { for (let i = 0; i < 100 && ptyState(k)!.alive; i++) await Bun.sleep(30); return ptyState(k)!.alive; };
203
+ startPty("polite", ["sh", "-c", "while read l; do case $l in */exit*) exit 0;; esac; done"], root);
204
+ expect(exitPty("polite", 60_000)).toBe(true);
205
+ expect(await wait("polite")).toBe(false);
206
+ startPty("stubborn", ["sh", "-c", "trap '' INT; while read l; do :; done"], root);
207
+ await Bun.sleep(100);
208
+ exitPty("stubborn", 300);
209
+ expect(await wait("stubborn")).toBe(false);
210
+ expect(exitPty("stubborn")).toBe(false);
211
+ });
212
+
201
213
  test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
202
214
  const { asking } = await import("./pty.ts");
203
215
  expect(asking("\x1b[1mWould you like to run the following command?\x1b[0m")).toBe(true);
package/cli/pty.ts CHANGED
@@ -120,6 +120,17 @@ const send = (p: Pty, d: string) => {
120
120
  try { (p.proc.stdin as Bun.FileSink).write(d); (p.proc.stdin as Bun.FileSink).flush(); } catch { /* host gone */ }
121
121
  };
122
122
 
123
+ // The tile's ×: ask the agent to quit as the user would (/exit), then end it if it is
124
+ // still there after `grace`. Escape first clears whatever is half-typed at its prompt.
125
+ export function exitPty(key: string, grace = 5000): boolean {
126
+ const p = ptys.get(key);
127
+ if (!p || p.exit !== null) return false;
128
+ send(p, "\x1b");
129
+ setTimeout(() => send(p, "/exit\r"), 100);
130
+ setTimeout(() => { if (p.exit === null) p.proc.kill(); }, grace).unref();
131
+ return true;
132
+ }
133
+
123
134
  export function resizePty(key: string, cols: number, rows: number) {
124
135
  const p = ptys.get(key);
125
136
  const c = Math.max(10, Math.min(500, cols | 0)), r = Math.max(5, Math.min(300, rows | 0));
@@ -0,0 +1,12 @@
1
+ import { test, expect } from "bun:test";
2
+ import { nextVersion, levelOf, summaryOf } from "./release.ts";
3
+
4
+ test("release picks the bump from the commits and names it from them", () => {
5
+ expect(levelOf("fix(board): x\ndocs: y")).toBe("patch");
6
+ expect(levelOf("docs: y\nfeat(board): x")).toBe("minor");
7
+ expect(levelOf("feat: x\n\nBREAKING: config moved")).toBe("major");
8
+ expect(nextVersion("1.43.3", "patch")).toBe("1.43.4");
9
+ expect(nextVersion("1.43.3", "minor")).toBe("1.44.0");
10
+ expect(nextVersion("1.43.3", "major")).toBe("2.0.0");
11
+ expect(summaryOf(["feat(board): a", "docs(notes): b", "fix: c"])).toBe("a; c");
12
+ });
package/cli/release.ts ADDED
@@ -0,0 +1,67 @@
1
+ // `bun run release [patch|minor|major] [summary]`: bump, commit, test, publish, push.
2
+ // With no level, the commits since the last tag decide it: a `feat` makes a new minor
3
+ // (1.43.3 -> 1.44.0), anything else a patch; "BREAKING" anywhere makes a major.
4
+ // The summary defaults to those commits' subjects, prefixes dropped. A bump commit
5
+ // already made but never published (HEAD is "Release <version>:" with no tag) is reused.
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+
8
+ const sh = (cmd: string[]) => {
9
+ const r = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "inherit" });
10
+ if (r.exitCode !== 0) throw new Error(`${cmd.join(" ")} failed`);
11
+ return r.stdout.toString().trim();
12
+ };
13
+ const run = (cmd: string[]) => {
14
+ if (Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }).exitCode !== 0) process.exit(1);
15
+ };
16
+
17
+ export function nextVersion(v: string, level: string) {
18
+ const [a, b, c] = v.split(".").map(Number);
19
+ return level === "major" ? `${a + 1}.0.0` : level === "minor" ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`;
20
+ }
21
+ export function levelOf(log: string) {
22
+ if (/BREAKING/.test(log)) return "major";
23
+ return /^feat(\(.*?\))?!?:/m.test(log) ? "minor" : "patch";
24
+ }
25
+ export function summaryOf(subjects: string[]) {
26
+ return subjects
27
+ .filter((s) => /^(feat|fix|perf)(\(.*?\))?!?:/.test(s))
28
+ .map((s) => s.replace(/^\w+(\(.*?\))?!?:\s*/, ""))
29
+ .join("; ");
30
+ }
31
+
32
+ if (import.meta.main) {
33
+ const args = process.argv.slice(2);
34
+ const forced = ["patch", "minor", "major"].includes(args[0]) ? args.shift() : null;
35
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"));
36
+ const tagged = (v: string) => sh(["git", "tag", "-l", `v${v}`]) !== "";
37
+ const head = sh(["git", "log", "-1", "--format=%s"]);
38
+ if (sh(["git", "status", "--porcelain", "--untracked-files=no"])) {
39
+ console.error("release: commit or drop your changes first");
40
+ process.exit(1);
41
+ }
42
+ if (head.startsWith(`Release ${pkg.version}:`) && !tagged(pkg.version) && !forced) {
43
+ console.log(`release: reusing the unpublished bump commit for ${pkg.version}`);
44
+ } else {
45
+ const last = sh(["git", "describe", "--tags", "--abbrev=0"]);
46
+ const log = sh(["git", "log", "--format=%s%n%b", `${last}..HEAD`]);
47
+ if (!log) {
48
+ console.error(`release: nothing since ${last}`);
49
+ process.exit(1);
50
+ }
51
+ const level = forced ?? levelOf(log);
52
+ const v = nextVersion(pkg.version, level);
53
+ const summary = args.join(" ") || summaryOf(sh(["git", "log", "--reverse", "--format=%s", `${last}..HEAD`]).split("\n")) || "maintenance";
54
+ writeFileSync("package.json", readFileSync("package.json", "utf8").replace(`"version": "${pkg.version}"`, `"version": "${v}"`));
55
+ const note = ".kaizen/notes/Project/Release and testing.md";
56
+ try {
57
+ writeFileSync(note, readFileSync(note, "utf8").replace(`version ${pkg.version} in this checkout`, `version ${v} in this checkout`));
58
+ } catch {}
59
+ sh(["git", "add", "package.json", note]);
60
+ sh(["git", "commit", "-q", "-m", `Release ${v}: ${summary}`]);
61
+ console.log(`release: ${pkg.version} -> ${v} (${level})`);
62
+ }
63
+ // Tests run in prepublishOnly; a failure stops here with the commit unpushed.
64
+ run(["bun", "publish"]);
65
+ run(["git", "push", "origin", "HEAD"]);
66
+ run(["bun", "run", "publish:gh"]);
67
+ }
package/cli/state.test.ts CHANGED
@@ -1228,6 +1228,8 @@ test("parent/child: a Parent line links a run and an idea to the run that filed
1228
1228
  expect(addCard(st, "next thing")).toEqual({ parent: "2026-01-02-b" });
1229
1229
  expect(addCard(st, "next thing")).toMatch(/already/);
1230
1230
  expect(addCard(st, "other", "nope")).toMatch(/no such run/);
1231
+ addCard(st, "titled", "2026-01-02-b", "the details\nline two");
1232
+ expect(readInbox(st).find((l) => l.text === "titled")?.notes).toContain("Parent: 2026-01-02-b\nthe details");
1231
1233
  expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
1232
1234
  rmSync(dirname(st), { recursive: true, force: true });
1233
1235
  });
package/cli/state.ts CHANGED
@@ -915,8 +915,9 @@ export function appendNote(state: string, target: { id: string } | { text: strin
915
915
  }
916
916
 
917
917
  // A card filed by a run: an open idea whose note names the run as its parent. The
918
- // parent is the one named, else the newest run still in flight.
919
- export function addCard(state: string, text: string, parent?: string): { parent: string } | string {
918
+ // parent is the one named, else the newest run still in flight. `details` go in the
919
+ // idea's notes below the Parent line, so the title stays a title.
920
+ export function addCard(state: string, text: string, parent?: string, details?: string): { parent: string } | string {
920
921
  text = text.trim().replace(/\s+/g, " ");
921
922
  if (!text) return "empty card";
922
923
  const id = parent ?? readRuns(state).find((r) => r.stage !== "done" && r.stage !== "abandoned")?.id;
@@ -924,7 +925,7 @@ export function addCard(state: string, text: string, parent?: string): { parent:
924
925
  if (!existsSync(join(state, "runs", id, "state.json"))) return `no such run: ${id}`;
925
926
  const lines = readInbox(state);
926
927
  if (lines.some((l) => l.status === "open" && l.text === text)) return "that card is already on the board";
927
- writeInbox(state, [...lines, { status: "open", text, notes: `Parent: ${id}` }]);
928
+ writeInbox(state, [...lines, { status: "open", text, notes: [`Parent: ${id}`, details?.trim()].filter(Boolean).join("\n") }]);
928
929
  return { parent: id };
929
930
  }
930
931
 
package/cli/web.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  reviewFindings, pickFindings, backlogCards, backlogItem, startedBacklog, closeBacklog, markBacklogDone, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
10
10
  launchMode, readSessions, ownerOf, runIndex, resumePrompt, runAgent, firstSession, webHosts,
11
11
  } from "./state.ts";
12
- import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
12
+ import { attachPty, writePty, resizePty, exitPty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
13
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";
@@ -389,6 +389,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
389
389
  return bad("websocket expected", 426);
390
390
  }
391
391
 
392
+ // The Terminal page's ×: the agent is asked to /exit, and ended if it will not.
393
+ if (req.method === "POST" && path === "/pty/exit") {
394
+ if (!local(req)) return bad("cross-origin write refused", 403);
395
+ let body: any;
396
+ try { body = await req.json(); } catch { return bad("json body expected"); }
397
+ const key = String(body.key ?? "");
398
+ if (!/^[\w-]+$/.test(key) || !ptyState(key)) return bad("no such terminal", 404);
399
+ return json({ ok: exitPty(key) });
400
+ }
401
+
392
402
  // Every note of one project, text included: search, tags and backlinks are the page's.
393
403
  if (req.method === "GET" && path === "/vault") {
394
404
  const dir = url.searchParams.get("dir") ?? state ?? join(home, ".kaizen");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.43.2",
3
+ "version": "1.44.0",
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": {
@@ -28,7 +28,7 @@
28
28
  "prepublishOnly": "bun test",
29
29
  "postpublish": "sh cli/release.sh",
30
30
  "publish:gh": "sh cli/publish-gh.sh",
31
- "release": "bun publish && bun run publish:gh"
31
+ "release": "bun run cli/release.ts"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -245,6 +245,12 @@ it. Everything else in this file holds unchanged.
245
245
  yourself, in that order, reading only each stage's declared inputs and writing its
246
246
  artifact before starting the next. The stage table below still applies; you are the
247
247
  agent for every row.
248
+ Read `spec-build.md` before building and `spec-main.md` before moving the run to
249
+ `review`: being the main thread does not excuse a stage its contract. Its *Preview
250
+ gate* holds in full — a result that shows on a screen needs a real screenshot under
251
+ `runs/<id>/attachments/` embedded in `## Preview`, taken by driving a headless browser
252
+ against the running app; "no browser session" or "same renderer as before" is not a
253
+ reason, it is the gate failing. Short path or not, no screenshot means not built.
248
254
  - `runner: full` — dispatch each stage as its own subagent, as follows.
249
255
 
250
256
  Under `full`, each stage runs as a subagent via the Agent tool, with the agent type
@@ -183,7 +183,10 @@ failed commit is reported as printed; the run is still `done`. With the setting
183
183
 
184
184
  A run may file new work as its own card instead of growing its scope — under either
185
185
  runner. Under `full`, subagents only suggest it in their report; the main thread files it,
186
- from the project folder: `kaizen card add "<the work, one line>"`. It links the card to
186
+ from the project folder: `kaizen card add "<short title>" --notes "<details>"`. The title
187
+ is what the card shows — a few words, like a commit subject; everything else (scope,
188
+ context, open questions, where it came from) goes in `--notes`, never in the title.
189
+ It links the card to
187
190
  the run in flight (`--parent <run id>` names another), and the link follows the card into
188
191
  the run it becomes. The board shows the parent's name on the child.
189
192
 
package/web/board/chat.ts CHANGED
@@ -4,6 +4,7 @@ import { mic, stopDictation } from "./dictation.ts";
4
4
  import { $, html, raw as rawHtml } from "./dom.ts";
5
5
  import { DK, draft } from "./drafts.ts";
6
6
  import { attach, brief, md } from "./markdown.ts";
7
+ import { rich } from "./rich.ts";
7
8
  import { toast } from "./toast.ts";
8
9
 
9
10
  // Notes are a chat: one entry per message, files attached by paste or picker.
@@ -21,6 +22,23 @@ function parseNotes(src) {
21
22
  }
22
23
  return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
23
24
  }
25
+ // A plain line written as "Label: … Label: …" reads as one paragraph per label.
26
+ // Markdown lines and fenced code are left as written.
27
+ export function pretty(t) {
28
+ let fence = false;
29
+ return t
30
+ .split("\n")
31
+ .map((l) => {
32
+ if (/^```/.test(l)) fence = !fence;
33
+ if (fence || /^\s*(#|```|\||[-*]\s|\d+\.\s|>)/.test(l)) return l;
34
+ const b = brief(l);
35
+ if (!b.parts.length) return l;
36
+ const head = l.startsWith(b.parts[0].label + ":") ? [] : [b.head];
37
+ return "\n" + [...head, ...b.parts.map((p) => `**${p.label}:** ${p.body}`)].join("\n\n") + "\n";
38
+ })
39
+ .join("\n")
40
+ .trim();
41
+ }
24
42
  let nfocus = null;
25
43
  export function chat(c, src, ro) {
26
44
  const fix = (t) => attach(t, c);
@@ -28,9 +46,13 @@ export function chat(c, src, ro) {
28
46
  const b = c.kind === "idea" ? brief(c.text) : { parts: [] };
29
47
  let h =
30
48
  (b.parts.length
31
- ? html`<section class="desc">${rawHtml(md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n")))}</section>`
49
+ ? html`<section class="desc">
50
+ ${rawHtml(md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n")))}
51
+ </section>`
32
52
  : "") +
33
- html`<div class="chat">${entries.map((n) => html`<div class="msg">${n.at ? html`<time>${n.at}</time>` : ""}${rawHtml(md(fix(n.text)))}</div>`)}${entries.length ? "" : html`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
53
+ html`<div class="chat">
54
+ ${entries.map((n) => html`<div class="msg">${n.at ? html`<time>${n.at}</time>` : ""}${rawHtml(md(fix(pretty(n.text))))}</div>`)}${entries.length ? "" : html`<p style="color:var(--dim)">No notes yet.</p>`}
55
+ </div>`;
34
56
  if (!ro)
35
57
  h += `<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
36
58
  $("pbody").innerHTML = rawHtml(h);
@@ -38,11 +60,12 @@ export function chat(c, src, ro) {
38
60
  const ta = $("pbody").querySelector("textarea"),
39
61
  files = [];
40
62
  draft(ta, "n:" + key(c));
63
+ const ed = rich(ta);
41
64
  // Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
42
65
  // so the note shows, and the new box takes focus back for the next note.
43
66
  if (nfocus === key(c)) {
44
67
  nfocus = null;
45
- ta.focus();
68
+ ed.focus();
46
69
  }
47
70
  {
48
71
  const m = mic([ta]);
@@ -60,7 +83,7 @@ export function chat(c, src, ro) {
60
83
  [...e.target.files].forEach(add);
61
84
  e.target.value = "";
62
85
  };
63
- ta.onpaste = (e) => {
86
+ ed.onpaste = (e) => {
64
87
  for (const it of e.clipboardData.items)
65
88
  if (it.kind === "file") {
66
89
  const f = it.getAsFile();
@@ -93,12 +116,12 @@ export function chat(c, src, ro) {
93
116
  } catch (e) {}
94
117
  toast("Noted");
95
118
  nfocus = key(c);
96
- ta.blur();
119
+ ed.blur();
97
120
  load();
98
121
  }
99
122
  };
100
123
  $("nsend").onclick = send;
101
- ta.onkeydown = (e) => {
124
+ ed.onkeydown = (e) => {
102
125
  if (e.key === "Enter" && !e.shiftKey) {
103
126
  e.preventDefault();
104
127
  send();
@@ -64,7 +64,8 @@ export function planQuestions(src) {
64
64
  export function brief(t) {
65
65
  // A label opens a sentence; only the first may follow the head without punctuation,
66
66
  // and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
67
- const re = /(^|[.!?)]\s+|\s)([A-Z][\w-]*(?: [a-z][\w-]*){0,3}):\s+/g,
67
+ const re =
68
+ /(^|[.!?)]\s+|\s)((?:[A-Z][A-Z-]+(?: [A-Z][A-Z-]+){1,3}|[A-Z][\w-]*(?: [a-z][\w-]*){0,3})(?: \([^)]*\))?):\s+/g,
68
69
  cut = [];
69
70
  let m;
70
71
  while ((m = re.exec(t)))
@@ -88,7 +89,7 @@ export function brief(t) {
88
89
  }
89
90
 
90
91
  // Enough markdown for kaizen's own files: headings, lists, fences, tables,
91
- // inline code, bold. Everything is escaped first. With `tick`, a backlog item
92
+ // inline code, bold, italic, hard line breaks. Everything is escaped first. With `tick`, a backlog item
92
93
  // (`- open: …`, `- done: …`, `- rejected: …`) shows its status as a checkbox.
93
94
  export function md(src, tick) {
94
95
  const lines = src.split("\n"),
@@ -99,6 +100,7 @@ export function md(src, tick) {
99
100
  esc(s)
100
101
  .replace(/`([^`]+)`/g, "<code>$1</code>")
101
102
  .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
103
+ .replace(/(^|[^*])\*([^*\s<][^*<]*?)\*(?!\*)/g, "$1<i>$2</i>")
102
104
  .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank"><img src="$2" alt="$1"></a>')
103
105
  .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank">$1</a>');
104
106
  const endList = () => {
@@ -182,7 +184,10 @@ export function md(src, tick) {
182
184
  i++;
183
185
  while (i < lines.length && lines[i].trim() && !/^(#|```|\||\s*[-*]\s|\s*\d+\.\s|---)/.test(lines[i]))
184
186
  buf.push(lines[i++]);
185
- out.push(`<p>${inl(buf.join(" "))}</p>`);
187
+ // A line ending in two spaces is a hard line break.
188
+ out.push(
189
+ `<p>${inl(buf.map((l, k) => (k < buf.length - 1 && / {2}$/.test(l) ? l.trimEnd() + "\0" : l)).join(" ")).replace(/\0 /g, "<br>")}</p>`,
190
+ );
186
191
  }
187
192
  endList();
188
193
  return out.join("");
@@ -0,0 +1,160 @@
1
+ import { esc } from "./dom.ts";
2
+ import { md } from "./markdown.ts";
3
+
4
+ // The card note box formats as you type: a contenteditable editor over a hidden
5
+ // textarea that stays the field's value, so drafts, dictation and send are unchanged.
6
+ // The editor is turned back into markdown on every edit; typing a markdown pattern
7
+ // (`**b**`, `` `c` ``, `- `, `1. `, `# `) re-renders the editor with md().
8
+
9
+ const ZW = /\u200b/g;
10
+ // Editor DOM to markdown. Paragraph blocks are separated by a blank line, list items
11
+ // by a newline, so md() renders them back as the same blocks.
12
+ export function toMd(root) {
13
+ const inl = (n) => {
14
+ // pre-wrap: a soft break typed in Chrome is a "\n" in the text.
15
+ if (n.nodeType === 3)
16
+ return n.textContent
17
+ .replace(ZW, "")
18
+ .replace(/\u00a0/g, " ")
19
+ .replace(/\n/g, " \n");
20
+ if (n.nodeType !== 1) return "";
21
+ const t = n.nodeName.toLowerCase(),
22
+ kids = () => [...n.childNodes].map(inl).join("");
23
+ if (t === "br") return " \n";
24
+ if (t === "b" || t === "strong") return ((s) => (s.trim() ? `**${s}**` : s))(kids());
25
+ if (t === "i" || t === "em") return ((s) => (s.trim() ? `*${s}*` : s))(kids());
26
+ if (t === "code") return ((s) => (s ? "`" + s + "`" : s))(n.textContent.replace(ZW, ""));
27
+ if (t === "img") return `![${n.getAttribute("alt") || ""}](${n.getAttribute("src")})`;
28
+ if (t === "a") {
29
+ const img = [...n.childNodes].find((c) => c.nodeName.toLowerCase() === "img");
30
+ return img ? inl(img) : `[${kids()}](${n.getAttribute("href")})`;
31
+ }
32
+ return kids();
33
+ };
34
+ const out = [];
35
+ let buf = "";
36
+ const flush = () => {
37
+ if (buf.trim()) out.push(buf.replace(/^\n+|\s*\n+$/g, ""));
38
+ buf = "";
39
+ };
40
+ for (const n of root.childNodes) {
41
+ const t = n.nodeType === 1 ? n.nodeName.toLowerCase() : "";
42
+ if (/^h[1-6]$/.test(t)) {
43
+ flush();
44
+ out.push("#".repeat(+t[1]) + " " + inl(n).trim());
45
+ } else if (t === "ul" || t === "ol") {
46
+ flush();
47
+ const lis = [...n.childNodes].filter((c) => c.nodeName.toLowerCase() === "li");
48
+ out.push(lis.map((li, i) => (t === "ol" ? `${i + 1}. ` : "- ") + inl(li).trim()).join("\n"));
49
+ } else if (t === "pre") {
50
+ flush();
51
+ out.push("```\n" + n.textContent + "\n```");
52
+ } else if (t === "p" || t === "div") {
53
+ flush();
54
+ buf = toMd(n);
55
+ flush();
56
+ } else buf += inl(n);
57
+ }
58
+ flush();
59
+ return out.join("\n\n");
60
+ }
61
+
62
+ // A line just typed into a markdown pattern md() would format. Text already inside
63
+ // code or bold is not raw markdown: a code span showing `**` must not match.
64
+ const INL = /\*\*[^*]+\*\*|`[^`]+`|(^|[^*])\*[^*\s][^*]*\*(?!\*)/;
65
+ const typed = (line) => {
66
+ const raw = [];
67
+ const walk = (n) => {
68
+ if (n.nodeType === 3) raw.push(n.textContent.replace(/\u00a0/g, " "));
69
+ else if (!/^(CODE|B|STRONG|I|EM)$/.test(n.nodeName)) n.childNodes.forEach(walk);
70
+ };
71
+ walk(line);
72
+ const txt = raw.join("");
73
+ return (
74
+ INL.test(txt) ||
75
+ (!/^(LI|H\d)$/.test(line.nodeName) && /^\s*([-*]|\d+\.|#{1,6})\s/.test(line.textContent.replace(/\u00a0/g, " ")))
76
+ );
77
+ };
78
+
79
+ export function rich(ta) {
80
+ const ed = document.createElement("div");
81
+ ed.className = "rich";
82
+ ed.contentEditable = "true";
83
+ ed.setAttribute("role", "textbox");
84
+ ed.setAttribute("aria-multiline", "true");
85
+ ed.dataset.ph = ta.placeholder;
86
+ ta.hidden = true;
87
+ ta.after(ed);
88
+ // Top-level lines, list items counted one each: where the caret goes back to.
89
+ const lines = () => [...ed.querySelectorAll(":scope > :not(ul):not(ol), :scope > ul > li, :scope > ol > li")];
90
+ const render = (src) => {
91
+ // An empty item or heading needs a <br> to hold the caret.
92
+ ed.innerHTML = md(src).replace(/<(li|h\d)><\/\1>/g, "<$1><br></$1>");
93
+ for (const l of lines()) {
94
+ const last = l.lastChild;
95
+ if (last && last.nodeType === 1 && /^(B|I|CODE|A)$/.test(last.nodeName)) l.append("\u200b");
96
+ }
97
+ };
98
+ const caretAt = (el) => {
99
+ const r = document.createRange();
100
+ r.selectNodeContents(el || ed);
101
+ r.collapse(false);
102
+ const s = getSelection();
103
+ s.removeAllRanges();
104
+ s.addRange(r);
105
+ };
106
+ let own = false;
107
+ const sync = () => {
108
+ own = true;
109
+ ta.value = toMd(ed);
110
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
111
+ own = false;
112
+ };
113
+ ed.addEventListener("input", () => {
114
+ const s = getSelection();
115
+ const all = lines();
116
+ const i = s.rangeCount ? all.findIndex((l) => l.contains(s.anchorNode)) : -1;
117
+ const line = all[i] || (s.anchorNode && s.anchorNode.parentNode === ed ? ed : null);
118
+ sync();
119
+ // Emptied: leave no stray <br>, so the placeholder shows again.
120
+ if (!ta.value.trim() && !ed.querySelector("img")) ed.replaceChildren();
121
+ else if (line && typed(line)) {
122
+ // ponytail: whole-editor re-render, caret to the end of the same line; per-line patching if notes grow long.
123
+ const at = i < 0 ? 0 : i;
124
+ render(ta.value);
125
+ caretAt(lines()[at]);
126
+ }
127
+ });
128
+ // Dictation or a restored draft changed the value: show it.
129
+ ta.addEventListener("input", () => {
130
+ if (own) return;
131
+ render(ta.value);
132
+ if (document.activeElement === ed) caretAt(null);
133
+ });
134
+ ed.addEventListener("keydown", (e) => {
135
+ const mod = e.ctrlKey || e.metaKey;
136
+ if (mod && e.key.toLowerCase() === "b") {
137
+ e.preventDefault();
138
+ document.execCommand("bold");
139
+ } else if (mod && e.key.toLowerCase() === "e") {
140
+ e.preventDefault();
141
+ const t = String(getSelection());
142
+ if (t) document.execCommand("insertHTML", false, `<code>${esc(t)}</code>\u200b`);
143
+ } else if (e.key === "Enter" && e.shiftKey) {
144
+ e.preventDefault();
145
+ // A new item in a list or after a heading; a soft line break in a paragraph.
146
+ const n = getSelection().anchorNode;
147
+ const el = n && (n.nodeType === 1 ? n : n.parentElement);
148
+ document.execCommand(el && el.closest("li,h1,h2,h3,h4,h5,h6") ? "insertParagraph" : "insertLineBreak");
149
+ }
150
+ });
151
+ // Files are handled by the caller; text comes in plain, never as foreign HTML.
152
+ ed.addEventListener("paste", (e) => {
153
+ if ([...e.clipboardData.items].some((it) => it.kind === "file")) return;
154
+ const t = e.clipboardData.getData("text/plain");
155
+ e.preventDefault();
156
+ if (t) document.execCommand("insertText", false, t);
157
+ });
158
+ if (ta.value) render(ta.value);
159
+ return ed;
160
+ }
@@ -1,6 +1,6 @@
1
1
  import { store } from "./store.ts";
2
2
  import { $, html, ico } from "./dom.ts";
3
- import { load } from "./api.ts";
3
+ import { load, post } from "./api.ts";
4
4
  import { termLoad } from "./agent-term.ts";
5
5
  import { open } from "./panel.ts";
6
6
  import { resume } from "./approvals.ts";
@@ -86,7 +86,9 @@ export function openTerms() {
86
86
  }
87
87
  // An ended session closed with × has nothing to come back to: no chip for it.
88
88
  const hid = [...all.keys()].filter((k) => lay.hidden.includes(k) && all.get(k).pty.alive);
89
- bar!.innerHTML = html`<span class="bar" role="group" aria-label="Layout">${MODES.map(([v, l]) => html`<button data-mode="${v}" class="${lay.mode === v ? "on" : ""}" aria-pressed="${String(lay.mode === v)}">${l}</button>`)}</span>${hid.length ? html`<span class="dim">Hidden</span>${hid.map((k) => html`<button class="chip" data-show="${k}" title="Show this terminal">${ico("plus")}${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}` : ""}${phone.matches && keys.length > 1 ? html`<span class="ttabs" role="group" aria-label="Terminals">${keys.map((k) => html`<button class="${k === one ? "on" : ""}" data-pick="${k}" aria-pressed="${String(k === one)}"><span class="tdot ${tst(all.get(k))}"></span>${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}</span>` : ""}`.s;
89
+ // The layout switch sits at the right of the page header.
90
+ $("tmodes").innerHTML = html`${MODES.map(([v, l]) => html`<button data-mode="${v}" class="${lay.mode === v ? "on" : ""}" aria-pressed="${String(lay.mode === v)}">${l}</button>`)}`.s;
91
+ bar!.innerHTML = html`${hid.length ? html`<span class="dim">Hidden</span>${hid.map((k) => html`<button class="chip" data-show="${k}" title="Show this terminal">${ico("plus")}${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}` : ""}${phone.matches && keys.length > 1 ? html`<span class="ttabs" role="group" aria-label="Terminals">${keys.map((k) => html`<button class="${k === one ? "on" : ""}" data-pick="${k}" aria-pressed="${String(k === one)}"><span class="tdot ${tst(all.get(k))}"></span>${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}</span>` : ""}`.s;
90
92
  bar!.querySelectorAll<HTMLElement>("[data-pick]").forEach(
91
93
  (b) =>
92
94
  (b.onclick = () => {
@@ -95,7 +97,7 @@ export function openTerms() {
95
97
  }),
96
98
  );
97
99
  bar!.querySelector(".ttabs .on")?.scrollIntoView({ block: "nearest", inline: "nearest" });
98
- bar!.querySelectorAll<HTMLElement>("[data-mode]").forEach(
100
+ $("tmodes").querySelectorAll<HTMLElement>("[data-mode]").forEach(
99
101
  (b) =>
100
102
  (b.onclick = () => {
101
103
  lay.mode = b.dataset.mode!;
@@ -140,7 +142,7 @@ export function openTerms() {
140
142
  const hd = t.el.querySelector("header")!;
141
143
  hd.title = (c.kind === "run" ? c.id : c.text) + " · " + c.where;
142
144
  // An ended run can be resumed from here; an idea whose agent ended has no run to resume.
143
- hd.innerHTML = html`<span class="tdot ${st}" role="img" title="${st}" aria-label="${st}"></span><b>${nameOf(c)} - ${projOf(c)}</b>${off ? html`<span class="tend">ended</span>` : ""}<span class="sp"></span>${off && c.kind === "run" ? html`<button class="btn sm" data-a="resume" title="Continue this run's agent">Resume</button>` : ""}<button class="ib sm" data-a="card" title="Open card" aria-label="Open card">${ico("open")}</button><button class="ib sm" data-a="max" title="${lay.max === k ? "Restore" : "Maximise"}" aria-label="${lay.max === k ? "Restore" : "Maximise"}">${ico(lay.max === k ? "min" : "max")}</button><button class="ib sm" data-a="hide" title="${off ? "Close" : "Hide"}" aria-label="${off ? "Close" : "Hide"}">${ico("close")}</button>`.s;
145
+ hd.innerHTML = html`<span class="tdot ${st}" role="img" title="${st}" aria-label="${st}"></span><b>${nameOf(c)} - ${projOf(c)}</b>${off ? html`<span class="tend">ended</span>` : ""}<span class="sp"></span>${off && c.kind === "run" ? html`<button class="btn sm" data-a="resume" title="Continue this run's agent">Resume</button>` : ""}<button class="ib sm" data-a="card" title="Open card" aria-label="Open card">${ico("open")}</button><button class="ib sm" data-a="max" title="${lay.max === k ? "Restore" : "Maximise"}" aria-label="${lay.max === k ? "Restore" : "Maximise"}">${ico(lay.max === k ? "min" : "max")}</button>${off ? "" : html`<button class="ib sm" data-a="hide" title="Hide" aria-label="Hide">${ico("dash")}</button>`}<button class="ib sm" data-a="${off ? "hide" : "exit"}" title="${off ? "Close" : "Exit session"}" aria-label="${off ? "Close" : "Exit session"}">${ico("close")}</button>`.s;
144
146
  t.el.classList.toggle("ended", off);
145
147
  t.el.classList.toggle("off", !!one && one !== k);
146
148
  t.el.style.gridColumn = "";
@@ -180,6 +182,10 @@ function wire(el: HTMLElement) {
180
182
  else if (a === "hide") {
181
183
  lay.hidden.push(k);
182
184
  if (lay.max === k) lay.max = null;
185
+ } else if (a === "exit") {
186
+ // The tile stays, turning "ended" with its Resume button when the agent is gone.
187
+ if (confirm("End this agent session? A run can be resumed from its tile or card.")) post("/pty/exit", { key: k }).then(() => load(true));
188
+ return;
183
189
  } else if (a === "card") {
184
190
  const c = sessions().get(k);
185
191
  if (c) open(c);