kaizen-agent 1.43.1 → 1.43.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -29,12 +29,18 @@ const save = () => {
29
29
  // key -> its tile and the function that unmounts its terminal.
30
30
  const tiles = new Map<string, { el: HTMLElement; off: (() => void) | null }>();
31
31
  let showing = false;
32
+ // A phone shows one terminal at a time, picked from tabs: stacked, each terminal took every
33
+ // swipe and the page could only be scrolled by its thin headers. The pick is per visit.
34
+ // Made on first show, so importing this module needs no window.
35
+ let phone: MediaQueryList | null = null,
36
+ pick: string | null = null;
32
37
 
33
38
  // The header: run name - project name. A run's name is its id without the date, as card chips name runs.
34
39
  const nameOf = (c) =>
35
40
  c.kind === "run"
36
41
  ? String(c.id || c.text).replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " ")
37
42
  : String(c.text).split("\n")[0].slice(0, 80);
43
+ const tst = (c) => (!c.pty.alive ? "ended" : c.pty.asking ? "asking" : "running");
38
44
  const projOf = (c) => String(c.where || "").split(/[\\/]/).filter(Boolean).pop() || c.where || "";
39
45
  // One card per session; the newest card wins a shared key.
40
46
  function sessions() {
@@ -52,6 +58,7 @@ export function termsStop() {
52
58
 
53
59
  export function openTerms() {
54
60
  showing = true;
61
+ if (!phone) (phone = matchMedia("(max-width:640px)")).onchange = () => showing && openTerms();
55
62
  // A /state tick mid-drag would rebuild the handle being dragged.
56
63
  if (document.body.classList.contains("tdrag")) return;
57
64
  const box = $("terms"),
@@ -68,6 +75,8 @@ export function openTerms() {
68
75
  const keys = [...all.keys()].filter((k) => !lay.hidden.includes(k)).sort((a, b) => ended(a) - ended(b) || rank(a) - rank(b));
69
76
  for (const k of keys) if (!lay.order.includes(k)) lay.order.push(k);
70
77
  if (lay.max && !keys.includes(lay.max)) lay.max = null;
78
+ // The one tile showing: on a phone the tab picked (the first by default), else the maximised one.
79
+ const one = phone.matches ? (pick && keys.includes(pick) ? pick : keys[0]) : lay.max;
71
80
  let grid = box.querySelector<HTMLElement>(".tgrid"),
72
81
  bar = box.querySelector<HTMLElement>(".tbar");
73
82
  if (!grid) {
@@ -77,7 +86,15 @@ export function openTerms() {
77
86
  }
78
87
  // An ended session closed with × has nothing to come back to: no chip for it.
79
88
  const hid = [...all.keys()].filter((k) => lay.hidden.includes(k) && all.get(k).pty.alive);
80
- 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>`)}` : ""}`.s;
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;
90
+ bar!.querySelectorAll<HTMLElement>("[data-pick]").forEach(
91
+ (b) =>
92
+ (b.onclick = () => {
93
+ pick = b.dataset.pick!;
94
+ openTerms();
95
+ }),
96
+ );
97
+ bar!.querySelector(".ttabs .on")?.scrollIntoView({ block: "nearest", inline: "nearest" });
81
98
  bar!.querySelectorAll<HTMLElement>("[data-mode]").forEach(
82
99
  (b) =>
83
100
  (b.onclick = () => {
@@ -118,24 +135,24 @@ export function openTerms() {
118
135
  mount(k, t);
119
136
  wire(el);
120
137
  }
121
- const st = !c.pty.alive ? "ended" : c.pty.asking ? "asking" : "running",
138
+ const st = tst(c),
122
139
  off = st === "ended";
123
140
  const hd = t.el.querySelector("header")!;
124
141
  hd.title = (c.kind === "run" ? c.id : c.text) + " · " + c.where;
125
142
  // An ended run can be resumed from here; an idea whose agent ended has no run to resume.
126
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;
127
144
  t.el.classList.toggle("ended", off);
128
- t.el.classList.toggle("off", !!lay.max && lay.max !== k);
145
+ t.el.classList.toggle("off", !!one && one !== k);
129
146
  t.el.style.gridColumn = "";
130
147
  grid.append(t.el); // in order
131
148
  }
132
149
  // Grid is √n, the last tile spanning what its row leaves empty; side by side is one row, stacked one column.
133
- const n = lay.max ? 1 : keys.length,
150
+ const n = one ? 1 : keys.length,
134
151
  cols = lay.mode === "cols" ? n : lay.mode === "rows" ? 1 : Math.ceil(Math.sqrt(n)),
135
152
  rows = Math.ceil(n / cols);
136
153
  // The column where the last row's tile starts spanning; borders right of it are not inner ones there.
137
154
  let spanFrom = cols;
138
- if (!lay.max && cols * rows > n) {
155
+ if (!one && cols * rows > n) {
139
156
  tiles.get(keys[keys.length - 1])!.el.style.gridColumn = `span ${cols * rows - n + 1}`;
140
157
  spanFrom = n - (rows - 1) * cols - 1;
141
158
  }
@@ -143,7 +160,7 @@ export function openTerms() {
143
160
  sz = lay.sizes[shape];
144
161
  const c = sz?.c?.length === cols ? sz.c : Array(cols).fill(1),
145
162
  r = sz?.r?.length === rows ? sz.r : Array(rows).fill(1);
146
- layout(grid, c, r, shape, spanFrom);
163
+ layout(grid, c, r, shape, spanFrom, !one);
147
164
  }
148
165
 
149
166
  function mount(k, t) {
@@ -177,7 +194,8 @@ function wire(el: HTMLElement) {
177
194
  openTerms();
178
195
  };
179
196
  el.querySelector("header")!.ondblclick = (e) => {
180
- if ((e.target as HTMLElement).closest("button")) return;
197
+ // A phone shows one tile anyway: a double-tap there would only leave desktop maximised.
198
+ if ((e.target as HTMLElement).closest("button") || phone?.matches) return;
181
199
  lay.max = lay.max === k ? null : k;
182
200
  save();
183
201
  openTerms();
@@ -215,7 +233,7 @@ const at = (tr: number[], i: number) => {
215
233
  S = tr.slice(0, i + 1).reduce((a, b) => a + b, 0);
216
234
  return `calc((100% - ${(tr.length - 1) * GAP}px) * ${S / F} + ${i * GAP + GAP / 2}px)`;
217
235
  };
218
- function layout(grid: HTMLElement, c: number[], r: number[], shape: string, spanFrom: number) {
236
+ function layout(grid: HTMLElement, c: number[], r: number[], shape: string, spanFrom: number, splits: boolean) {
219
237
  grid.querySelectorAll(".tsplit").forEach((s) => s.remove());
220
238
  const place = () => {
221
239
  grid.style.gridTemplateColumns = c.map((x) => x + "fr").join(" ");
@@ -227,7 +245,7 @@ function layout(grid: HTMLElement, c: number[], r: number[], shape: string, span
227
245
  h.style.bottom = i >= spanFrom ? `calc(100% - ${at(r, r.length - 2)} + ${GAP / 2}px)` : "0px";
228
246
  });
229
247
  };
230
- if (!lay.max)
248
+ if (splits)
231
249
  for (const [axis, tr] of [["c", c], ["r", r]] as const)
232
250
  for (let i = 0; i < tr.length - 1; i++) {
233
251
  const h = document.createElement("div");
package/web/board.html CHANGED
@@ -240,7 +240,7 @@ dialog.kz{border:0;padding:0;background:none;width:min(480px,calc(100vw - 32px))
240
240
  aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-direction:column}#pbody .term{flex:1;min-height:0;min-height:320px;background:var(--bg);border-radius:var(--r-s)}#pbody .term .xterm{height:100%;padding:4px}#pbody .term .xterm-viewport{background:transparent!important}.termkeys{padding:6px 0}.termkeys button{height:52px;padding:0;border:1px solid var(--line);border-bottom-width:4px;border-radius:10px;background:var(--bg-2);color:var(--ink-hi);font:600 17px var(--mono);touch-action:manipulation}.termkeys button:active{transform:translateY(2px);border-bottom-width:2px;background:var(--hover)}.termkeys button.on{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}.keygrid{display:grid;grid-template-columns:repeat(6,1fr);gap:8px}.ctrlpad{margin-bottom:8px}.ctrlpad div{display:flex;gap:4px;margin-bottom:5px}.ctrlpad div:nth-child(2){padding:0 5%}.ctrlpad div:nth-child(3){padding:0 10%}.ctrlpad button{flex:1;min-width:0;height:44px;font-size:15px}.termkeys [hidden]{display:none}.keytop{display:flex;gap:8px;margin-bottom:6px}.keytop button{flex:1;height:30px;border-bottom-width:1px;font-size:14px}
241
241
  .desc{border-bottom:1px solid var(--line);padding-bottom:6px;margin-bottom:14px}.desc h3{margin-top:14px}
242
242
  .chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
243
- .notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
243
+ .notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}.notes .rich{min-height:160px;max-height:50vh;overflow:auto;border:1px solid var(--line);border-radius:var(--r-s);padding:8px 12px;background:var(--bg-2);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}.notes .rich:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}.notes .rich:empty::before{content:attr(data-ph);color:var(--dim)}.notes .rich>:first-child{margin-top:0}
244
244
  .inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
245
245
  .inl.fix{margin-top:16px}.inl.fix label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.fix label.off{color:var(--dim);cursor:default}.inl.fix label.done span{text-decoration:line-through}.inl.fix input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
246
246
  .inl.qa{max-height:60vh;overflow:auto}.inl.qa fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:4px}.inl.qa legend{font-size:13px;font-weight:500;padding:0;margin-bottom:4px}.inl.qa label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.qa label input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
@@ -283,8 +283,9 @@ body.termv #terms{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px
283
283
  .tsplit{position:absolute;z-index:2;touch-action:none}.tsplit.c{top:0;width:10px;margin-left:-5px;cursor:col-resize}.tsplit.r{left:0;right:0;height:10px;margin-top:-5px;cursor:row-resize}.tsplit:hover,.tdrag .tsplit:active{background:color-mix(in srgb,var(--accent) 35%,transparent)}
284
284
  body.tdrag{user-select:none}body.tdrag .tile{pointer-events:none}
285
285
  @media(max-width:900px){body.termv #terms{padding-inline:16px}}
286
- /* A phone has no room for a grid: tiles stack, each most of a screen tall, and the splits go */
287
- @media(max-width:640px){.tbar .bar{display:none}.tgrid{display:flex;flex-direction:column;overflow:auto}.tile{flex:none;height:70vh}.tile.off{display:none}.tsplit{display:none}}
286
+ /* A phone has no room for a grid or a stack: one terminal fills the page, and tabs above pick which */
287
+ .ttabs{display:none}
288
+ @media(max-width:640px){.tbar .bar,.tile [data-a=max],.tsplit{display:none}.ttabs{display:flex;order:-1;flex:1 0 100%;min-width:0;gap:6px;overflow-x:auto;scrollbar-width:none}.ttabs button{flex:none;display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border:1px solid var(--line);border-radius:var(--r-pill);background:none;color:var(--dim);font:inherit;font-size:12px;white-space:nowrap}.ttabs button.on{color:var(--ink);background:var(--hover);border-color:var(--accent)}.tgrid{display:flex;flex-direction:column}.tile{flex:1}.tile.off{display:none}}
288
289
  aside.docked{position:static;z-index:auto;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
289
290
  aside.docked>header,aside.docked>.body{padding-inline:28px}
290
291
  aside.docked>header{border-bottom:0;padding-top:28px;padding-bottom:12px}