kaizen-agent 1.42.2 → 1.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/agents/kaizen-reviewer.md +1 -1
  2. package/cli/backlog.test.ts +133 -0
  3. package/cli/board.test.ts +10 -4
  4. package/cli/browser.test.ts +1 -1
  5. package/cli/browser.ts +12 -7
  6. package/cli/html.test.ts +19 -0
  7. package/cli/notes.ts +2 -6
  8. package/cli/pty.ts +2 -1
  9. package/cli/settings.test.ts +38 -0
  10. package/cli/settings.ts +37 -30
  11. package/cli/state.test.ts +86 -3
  12. package/cli/state.ts +245 -45
  13. package/cli/web.ts +67 -16
  14. package/package.json +1 -1
  15. package/skills/kaizen/README.md +0 -4
  16. package/skills/kaizen/SKILL.md +4 -1
  17. package/skills/kaizen/config.default.yml +1 -50
  18. package/skills/kaizen/docs.md +3 -2
  19. package/skills/kaizen/settings.md +10 -3
  20. package/skills/kaizen/spec-build.md +3 -2
  21. package/skills/kaizen/spec-main.md +40 -5
  22. package/skills/kaizen/spec-review.md +2 -1
  23. package/skills/kaizen-help/SKILL.md +0 -4
  24. package/web/board/agent-term.ts +1 -1
  25. package/web/board/api.ts +2 -0
  26. package/web/board/approvals.ts +3 -3
  27. package/web/board/cards.ts +284 -108
  28. package/web/board/chat.ts +4 -4
  29. package/web/board/dom.ts +17 -5
  30. package/web/board/fixes.ts +6 -8
  31. package/web/board/git.ts +3 -3
  32. package/web/board/icebox.ts +52 -70
  33. package/web/board/keys.ts +9 -6
  34. package/web/board/live.ts +3 -2
  35. package/web/board/main.ts +1 -0
  36. package/web/board/panel.ts +22 -16
  37. package/web/board/projects.ts +11 -16
  38. package/web/board/settings.ts +60 -29
  39. package/web/board/store.ts +2 -0
  40. package/web/board/terms.ts +279 -0
  41. package/web/board/update.ts +7 -7
  42. package/web/board/views.ts +19 -5
  43. package/web/board.html +40 -2
  44. package/web/board.js +25 -24
  45. package/web/notebook.js +1 -1
  46. package/web/notes.html +2 -0
  47. package/web/term.js +1 -1
@@ -0,0 +1,279 @@
1
+ import { store } from "./store.ts";
2
+ import { $, html, ico } from "./dom.ts";
3
+ import { load } from "./api.ts";
4
+ import { termLoad } from "./agent-term.ts";
5
+ import { open } from "./panel.ts";
6
+ import { resume } from "./approvals.ts";
7
+
8
+ // ---- the Terminal page: every in-card agent session the board knows, Ideas to Done, tiled.
9
+ // A tile is one more kzTerm mount on /pty beside the card panel's (attachPty takes many
10
+ // listeners). Mounts outlive redraws: a /state tick moves tiles around, it never reconnects.
11
+ // Layout is per browser in kz-terms: tile order, hidden tiles, the maximised one, and the
12
+ // split sizes per grid shape.
13
+ // ponytail: a √n grid with draggable borders, not an i3-style split tree; add one if a grid falls short.
14
+ // mode: grid (√n), cols (side by side) or rows (stacked, so borders drag up and down).
15
+ type Lay = { mode: string; order: string[]; hidden: string[]; max: string | null; sizes: Record<string, { c: number[]; r: number[] }> };
16
+ const MODES = [["grid", "Grid"], ["cols", "Side by side"], ["rows", "Stacked"]];
17
+ const GAP = 6;
18
+ let lay: Lay = { mode: "grid", order: [], hidden: [], max: null, sizes: {} };
19
+ try {
20
+ lay = { ...lay, ...JSON.parse(localStorage.getItem("kz-terms") || "{}") };
21
+ } catch (e) {}
22
+ const save = () => {
23
+ lay.order = lay.order.slice(-100);
24
+ lay.hidden = lay.hidden.slice(-100);
25
+ try {
26
+ localStorage.setItem("kz-terms", JSON.stringify(lay));
27
+ } catch (e) {}
28
+ };
29
+ // key -> its tile and the function that unmounts its terminal.
30
+ const tiles = new Map<string, { el: HTMLElement; off: (() => void) | null }>();
31
+ let showing = false;
32
+
33
+ // The header: run name - project name. A run's name is its id without the date, as card chips name runs.
34
+ const nameOf = (c) =>
35
+ c.kind === "run"
36
+ ? String(c.id || c.text).replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " ")
37
+ : String(c.text).split("\n")[0].slice(0, 80);
38
+ const projOf = (c) => String(c.where || "").split(/[\\/]/).filter(Boolean).pop() || c.where || "";
39
+ // One card per session; the newest card wins a shared key.
40
+ function sessions() {
41
+ const m = new Map();
42
+ for (const c of store.S.cards) if (c.pty && !c.archived) m.set(c.pty.key, c);
43
+ return m;
44
+ }
45
+
46
+ export function termsStop() {
47
+ showing = false;
48
+ for (const t of tiles.values()) t.off?.();
49
+ tiles.clear();
50
+ $("terms").innerHTML = "";
51
+ }
52
+
53
+ export function openTerms() {
54
+ showing = true;
55
+ // A /state tick mid-drag would rebuild the handle being dragged.
56
+ if (document.body.classList.contains("tdrag")) return;
57
+ const box = $("terms"),
58
+ all = sessions();
59
+ for (const [k, t] of tiles)
60
+ if (!all.has(k) || lay.hidden.includes(k)) {
61
+ t.off?.();
62
+ t.el.remove();
63
+ tiles.delete(k);
64
+ }
65
+ const rank = (k) => (lay.order.includes(k) ? lay.order.indexOf(k) : 1e9);
66
+ // Live tiles first, ended ones after; each in the saved order.
67
+ const ended = (k) => (all.get(k).pty.alive ? 0 : 1);
68
+ const keys = [...all.keys()].filter((k) => !lay.hidden.includes(k)).sort((a, b) => ended(a) - ended(b) || rank(a) - rank(b));
69
+ for (const k of keys) if (!lay.order.includes(k)) lay.order.push(k);
70
+ if (lay.max && !keys.includes(lay.max)) lay.max = null;
71
+ let grid = box.querySelector<HTMLElement>(".tgrid"),
72
+ bar = box.querySelector<HTMLElement>(".tbar");
73
+ if (!grid) {
74
+ box.innerHTML = `<div class="tbar"></div><div class="tgrid"></div>`;
75
+ grid = box.querySelector(".tgrid")!;
76
+ bar = box.querySelector(".tbar")!;
77
+ }
78
+ // An ended session closed with × has nothing to come back to: no chip for it.
79
+ 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;
81
+ bar!.querySelectorAll<HTMLElement>("[data-mode]").forEach(
82
+ (b) =>
83
+ (b.onclick = () => {
84
+ lay.mode = b.dataset.mode!;
85
+ lay.max = null;
86
+ save();
87
+ openTerms();
88
+ }),
89
+ );
90
+ bar!.querySelectorAll<HTMLElement>("[data-show]").forEach(
91
+ (b) =>
92
+ (b.onclick = () => {
93
+ lay.hidden = lay.hidden.filter((k) => k !== b.dataset.show);
94
+ save();
95
+ openTerms();
96
+ }),
97
+ );
98
+ grid.querySelector(".tempty")?.remove();
99
+ if (!keys.length) {
100
+ grid.style.cssText = "";
101
+ grid.querySelectorAll(".tsplit").forEach((s) => s.remove());
102
+ grid.insertAdjacentHTML(
103
+ "beforeend",
104
+ html`<div class="empty tempty"><div class="mascot"></div><b>${hid.length ? "Every terminal is hidden" : "No terminals running"}</b><p>Runs started with the agent in its card show here, from Ideas to Done.</p></div>`.s,
105
+ );
106
+ return;
107
+ }
108
+ for (const k of keys) {
109
+ const c = all.get(k);
110
+ let t = tiles.get(k);
111
+ if (!t) {
112
+ const el = document.createElement("section");
113
+ el.className = "tile";
114
+ el.dataset.k = k;
115
+ el.innerHTML = `<header draggable="true"></header><div class="term" aria-label="Agent terminal"></div>`;
116
+ t = { el, off: null };
117
+ tiles.set(k, t);
118
+ mount(k, t);
119
+ wire(el);
120
+ }
121
+ const st = !c.pty.alive ? "ended" : c.pty.asking ? "asking" : "running",
122
+ off = st === "ended";
123
+ const hd = t.el.querySelector("header")!;
124
+ hd.title = (c.kind === "run" ? c.id : c.text) + " · " + c.where;
125
+ // An ended run can be resumed from here; an idea whose agent ended has no run to resume.
126
+ 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
+ t.el.classList.toggle("ended", off);
128
+ t.el.classList.toggle("off", !!lay.max && lay.max !== k);
129
+ t.el.style.gridColumn = "";
130
+ grid.append(t.el); // in order
131
+ }
132
+ // 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,
134
+ cols = lay.mode === "cols" ? n : lay.mode === "rows" ? 1 : Math.ceil(Math.sqrt(n)),
135
+ rows = Math.ceil(n / cols);
136
+ // The column where the last row's tile starts spanning; borders right of it are not inner ones there.
137
+ let spanFrom = cols;
138
+ if (!lay.max && cols * rows > n) {
139
+ tiles.get(keys[keys.length - 1])!.el.style.gridColumn = `span ${cols * rows - n + 1}`;
140
+ spanFrom = n - (rows - 1) * cols - 1;
141
+ }
142
+ const shape = `${lay.mode}:${cols}x${rows}`,
143
+ sz = lay.sizes[shape];
144
+ const c = sz?.c?.length === cols ? sz.c : Array(cols).fill(1),
145
+ r = sz?.r?.length === rows ? sz.r : Array(rows).fill(1);
146
+ layout(grid, c, r, shape, spanFrom);
147
+ }
148
+
149
+ function mount(k, t) {
150
+ termLoad()
151
+ .then((m) => {
152
+ if (tiles.get(k) !== t) return;
153
+ t.off = m(t.el.querySelector(".term"), k, () => load(true));
154
+ })
155
+ .catch(() => (t.el.querySelector(".term")!.textContent = "The terminal could not load."));
156
+ }
157
+
158
+ function wire(el: HTMLElement) {
159
+ const k = el.dataset.k!;
160
+ el.querySelector("header")!.onclick = (e) => {
161
+ const a = (e.target as HTMLElement).closest<HTMLElement>("[data-a]")?.dataset.a;
162
+ if (a === "max") lay.max = lay.max === k ? null : k;
163
+ else if (a === "hide") {
164
+ lay.hidden.push(k);
165
+ if (lay.max === k) lay.max = null;
166
+ } else if (a === "card") {
167
+ const c = sessions().get(k);
168
+ if (c) open(c);
169
+ return;
170
+ } else if (a === "resume") {
171
+ const c = sessions().get(k);
172
+ // resume() asks the board to open the card on its new session; here the new tile shows it.
173
+ if (c) resume(c, (e.target as HTMLElement).closest("button")).then(() => (store.wantTerm = null));
174
+ return;
175
+ } else return;
176
+ save();
177
+ openTerms();
178
+ };
179
+ el.querySelector("header")!.ondblclick = (e) => {
180
+ if ((e.target as HTMLElement).closest("button")) return;
181
+ lay.max = lay.max === k ? null : k;
182
+ save();
183
+ openTerms();
184
+ };
185
+ // Drag a header onto another tile: the two swap places.
186
+ el.querySelector("header")!.ondragstart = (e) => {
187
+ e.dataTransfer!.setData("text/kz-term", k);
188
+ e.dataTransfer!.effectAllowed = "move";
189
+ };
190
+ el.ondragover = (e) => {
191
+ if (!e.dataTransfer!.types.includes("text/kz-term")) return;
192
+ e.preventDefault();
193
+ el.classList.add("drop");
194
+ };
195
+ el.ondragleave = () => el.classList.remove("drop");
196
+ el.ondrop = (e) => {
197
+ el.classList.remove("drop");
198
+ const from = e.dataTransfer!.getData("text/kz-term");
199
+ if (!from || from === k) return;
200
+ e.preventDefault();
201
+ const i = lay.order.indexOf(from),
202
+ j = lay.order.indexOf(k);
203
+ if (i < 0 || j < 0) return;
204
+ [lay.order[i], lay.order[j]] = [lay.order[j], lay.order[i]];
205
+ save();
206
+ openTerms();
207
+ };
208
+ }
209
+
210
+ // Track sizes in fr, and a handle on each inner border. A handle's centre is placed in CSS:
211
+ // the tracks before it share (100% - gaps) by their fr, plus the gaps before it. A column
212
+ // handle stops above the last row where that row's spanning tile covers the border.
213
+ const at = (tr: number[], i: number) => {
214
+ const F = tr.reduce((a, b) => a + b, 0),
215
+ S = tr.slice(0, i + 1).reduce((a, b) => a + b, 0);
216
+ return `calc((100% - ${(tr.length - 1) * GAP}px) * ${S / F} + ${i * GAP + GAP / 2}px)`;
217
+ };
218
+ function layout(grid: HTMLElement, c: number[], r: number[], shape: string, spanFrom: number) {
219
+ grid.querySelectorAll(".tsplit").forEach((s) => s.remove());
220
+ const place = () => {
221
+ grid.style.gridTemplateColumns = c.map((x) => x + "fr").join(" ");
222
+ grid.style.gridTemplateRows = r.map((x) => x + "fr").join(" ");
223
+ grid.querySelectorAll<HTMLElement>(".tsplit").forEach((h) => {
224
+ const i = +h.dataset.i!;
225
+ if (h.dataset.axis === "r") return void (h.style.top = at(r, i));
226
+ h.style.left = at(c, i);
227
+ h.style.bottom = i >= spanFrom ? `calc(100% - ${at(r, r.length - 2)} + ${GAP / 2}px)` : "0px";
228
+ });
229
+ };
230
+ if (!lay.max)
231
+ for (const [axis, tr] of [["c", c], ["r", r]] as const)
232
+ for (let i = 0; i < tr.length - 1; i++) {
233
+ const h = document.createElement("div");
234
+ h.className = "tsplit " + axis;
235
+ h.dataset.axis = axis;
236
+ h.dataset.i = String(i);
237
+ h.setAttribute("role", "separator");
238
+ h.setAttribute("aria-orientation", axis === "c" ? "vertical" : "horizontal");
239
+ h.title = "Drag to resize, double-click to even out";
240
+ drag(h, grid, axis, tr, i, place, () => {
241
+ lay.sizes[shape] = { c: [...c], r: [...r] };
242
+ save();
243
+ });
244
+ grid.append(h);
245
+ }
246
+ place();
247
+ }
248
+
249
+ function drag(h: HTMLElement, grid: HTMLElement, axis: "c" | "r", tr: number[], i: number, place: () => void, keep: () => void) {
250
+ h.ondblclick = () => {
251
+ tr.fill(1);
252
+ keep();
253
+ place();
254
+ };
255
+ h.onpointerdown = (e) => {
256
+ e.preventDefault();
257
+ h.setPointerCapture(e.pointerId);
258
+ document.body.classList.add("tdrag");
259
+ const F = tr.reduce((a, b) => a + b, 0),
260
+ span = (axis === "c" ? grid.clientWidth : grid.clientHeight) - (tr.length - 1) * GAP,
261
+ min = F * 0.1,
262
+ from = axis === "c" ? e.clientX : e.clientY,
263
+ a = tr[i],
264
+ b = tr[i + 1];
265
+ h.onpointermove = (m) => {
266
+ const d = Math.max(min - a, Math.min(b - min, (((axis === "c" ? m.clientX : m.clientY) - from) / span) * F));
267
+ tr[i] = a + d;
268
+ tr[i + 1] = b - d;
269
+ place();
270
+ };
271
+ h.onpointerup = h.onpointercancel = () => {
272
+ h.onpointermove = h.onpointerup = h.onpointercancel = null;
273
+ document.body.classList.remove("tdrag");
274
+ keep();
275
+ };
276
+ };
277
+ }
278
+
279
+ export const termsShowing = () => showing;
@@ -1,5 +1,5 @@
1
1
  import { post } from "./api.ts";
2
- import { $, esc } from "./dom.ts";
2
+ import { $, html } from "./dom.ts";
3
3
  import { openSettings } from "./settings.ts";
4
4
  import { view } from "./views.ts";
5
5
 
@@ -21,16 +21,16 @@ export function updBar() {
21
21
  const d = document.createElement("div");
22
22
  d.className = `updbar${upd ? "" : " quiet"}`;
23
23
  if (!upd) {
24
- d.innerHTML = `<span id="upd-msg" aria-live="polite">${ver ? `kaizen ${esc(ver.version)}` : "kaizen"}</span><button class="btn" id="upd-chk">Check for updates</button>`;
24
+ d.innerHTML = html`<span id="upd-msg" aria-live="polite">${ver ? `kaizen ${ver.version}` : "kaizen"}</span><button class="btn" id="upd-chk">Check for updates</button>`;
25
25
  } else {
26
26
  // What's new: each newer release's one-line title, shown on hover, focus or click.
27
- const notes = (upd.notes || []).map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`).join("");
28
- const news = notes
29
- ? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
27
+ const notes = (upd.notes || []).map((n) => html`<li><b>${n.v}</b> ${n.text}</li>`);
28
+ const news = notes.length
29
+ ? html`<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
30
30
  : "";
31
- d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
31
+ d.innerHTML = html`<span>kaizen <b>${upd.latest}</b> is available. You are running ${upd.version}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
32
32
  }
33
- if (ver && ver.failed) d.insertAdjacentHTML("afterbegin", `<span class="updfail" role="alert">Last update failed: ${esc(ver.failed)}</span>`);
33
+ if (ver && ver.failed) d.insertAdjacentHTML("afterbegin", html`<span class="updfail" role="alert">Last update failed: ${ver.failed}</span>`);
34
34
  $("pbody").prepend(d);
35
35
  const check = $("upd-chk");
36
36
  check.onclick = async () => {
@@ -3,8 +3,9 @@ import { $ } from "./dom.ts";
3
3
  import { openSources } from "./icebox.ts";
4
4
  import { close } from "./panel.ts";
5
5
  import { openSettings } from "./settings.ts";
6
+ import { openTerms, termsStop } from "./terms.ts";
6
7
 
7
- // ---- views: the board, or Settings and the Icebox as pages in the docked panel, each with its own address
8
+ // ---- views: the board, Settings and the Icebox as pages in the docked panel, or the Terminal page; each with its own address
8
9
  export let view = "board";
9
10
  export const markView = (v) => {
10
11
  document
@@ -13,28 +14,41 @@ export const markView = (v) => {
13
14
  a.dataset.view === v ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current"),
14
15
  );
15
16
  // The Icebox is titled in the header, top left, like the board.
16
- document.querySelector(".hdr h1").textContent = v === "sources" ? "Icebox" : "Board";
17
+ document.querySelector(".hdr h1").textContent = v === "sources" ? "Icebox" : v === "terminal" ? "Terminal" : "Board";
17
18
  };
18
19
  export function go(v, push) {
19
20
  store.pageGen++;
20
21
  if (v === "icebox") v = "sources";
21
- if (!["settings", "sources"].includes(v)) v = "board";
22
+ if (!["settings", "sources", "terminal"].includes(v)) v = "board";
22
23
  const was = view;
23
24
  view = v;
24
25
  markView(v);
25
26
  document.body.classList.toggle("paged", v !== "board");
26
27
  document.body.classList.toggle("ice", v === "sources");
28
+ document.body.classList.toggle("termv", v === "terminal");
27
29
  if (push && v !== was)
28
30
  history.pushState(null, "", v === "board" ? location.pathname : "?view=" + (v === "sources" ? "icebox" : v));
29
31
  const p = $("panel");
30
- if (v === "board") {
31
- if (was === "board") return;
32
+ if (v === "terminal") openTerms();
33
+ else termsStop();
34
+ // The Terminal page is the board's own area, not the docked panel: the panel closes as on the board.
35
+ if (v === "board" || v === "terminal") {
36
+ if (was === "board" || was === "terminal") {
37
+ if (v === "terminal" && was === "board") {
38
+ view = "board";
39
+ close();
40
+ view = v;
41
+ }
42
+ return;
43
+ }
32
44
  // Undocked without the slide: it was a page, not a drawer leaving.
33
45
  p.style.transition = "none";
34
46
  p.classList.remove("open", "docked");
35
47
  p.offsetWidth;
36
48
  p.style.transition = "";
49
+ view = "board";
37
50
  close();
51
+ view = v;
38
52
  return;
39
53
  }
40
54
  p.classList.add("docked");
package/web/board.html CHANGED
@@ -144,6 +144,18 @@ section h2{margin:0;font-size:13px;color:var(--ink);font-weight:500;display:flex
144
144
  section h2 i{font-style:normal;font-weight:400;font-size:12px;color:var(--dim)}
145
145
  section h2 i:empty{display:none}
146
146
  section h2 .ib{margin-left:auto}
147
+ /* Ideas column: its title is two icon tabs, Ideas | Backlog; on Backlog a severity filter icon */
148
+ .itabs{display:flex;align-items:center;gap:2px}
149
+ .itabs [role=tab]{min-height:26px;padding:0 8px;border:0;border-radius:13px;background:none;color:var(--dim);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
150
+ .itabs [role=tab] svg{width:14px;height:14px;stroke-width:1.7}
151
+ .itabs [role=tab][aria-selected=true]{color:var(--ink);font-weight:500;background:color-mix(in srgb,var(--dim) 14%,transparent)}
152
+ .itabs i{font-style:normal}
153
+ .itabs button:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
154
+ section h2 .sevf{margin-left:auto}section h2 .sevf~.ib{margin-left:0}
155
+ section h2 .sevf.on{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent)}
156
+ .sev-pop{top:30px;right:0;min-width:180px}
157
+ .sev-pop button i{font-style:normal;color:var(--dim);margin-left:auto;background:none;padding:0}
158
+ .sev-pop button[aria-checked=true]{color:var(--accent)}
147
159
  ul{list-style:none;margin:0;padding:4px 0 0;display:flex;flex-direction:column;gap:6px}
148
160
 
149
161
  /* card, Linear grammar in our palette
@@ -217,7 +229,8 @@ section li.hint{display:block;padding:14px;border:1px dashed var(--line);border-
217
229
  .inl textarea,.notes textarea{min-height:72px;resize:vertical}
218
230
  .notes textarea{min-height:160px}
219
231
  .inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
220
- .inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end;align-items:center}
232
+ .inl .row,.notes .row{display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end;align-items:center}
233
+ .split{position:relative;display:inline-flex}.split>#ns{border-top-right-radius:0;border-bottom-right-radius:0}.split>#nm{border-top-left-radius:0;border-bottom-left-radius:0;border-left-color:color-mix(in srgb,var(--on-accent) 35%,var(--accent));padding:0 7px;display:inline-flex;align-items:center}.split>#nm svg{width:14px;height:14px;stroke-width:2}.split .menu-pop{right:0;top:calc(100% + 4px);z-index:9;white-space:nowrap;min-width:0;padding:4px}.split .menu-pop button{padding:5px 8px;font-size:12px;gap:6px}.split .menu-pop svg{width:13px;height:13px}.split .menu-pop button:focus-visible{outline-width:1px;outline-offset:-1px}.split .menu-pop[hidden]{display:none}
221
234
  .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
235
  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}dialog.viewer{width:min(1100px,calc(100vw - 32px));height:calc(100dvh - 32px);background:var(--bg);border-radius:var(--r);display:none;flex-direction:column;overflow:hidden}dialog.viewer[open]{display:flex}.viewer-hd{display:flex;gap:12px;align-items:center;padding:8px 12px;border-bottom:1px solid var(--line)}.viewer-hd span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}dialog.viewer img{flex:1;min-height:0;width:100%;object-fit:contain}dialog.viewer iframe{flex:1;border:0;width:100%;background:#fff}
223
236
  .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}
@@ -256,6 +269,22 @@ aside.open{transform:none;visibility:visible}
256
269
  body.paged #board,body.paged .ftr,body.paged .scrim,body.paged #bq{display:none}
257
270
  /* The Icebox keeps the project bar with its own title, without New idea or the panel's header; Settings has none */
258
271
  body.paged:not(.ice) .hdr,body.ice aside.docked>header{display:none}body.ice #newtop{visibility:hidden}
272
+ /* The Terminal page: the project bar stays (it picks whose terminals show), the board gives way to the tiles */
273
+ #terms{display:none}body.termv.paged .hdr{display:flex}body.termv #newtop,body.termv #fbtn{display:none!important}
274
+ body.termv #terms{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 20px 14px 28px}
275
+ .tbar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px}.tbar .bar{margin-right:8px}.tbar .bar button{padding:0 10px;font-size:12px}.tbar .chip{cursor:pointer;background:none;border:0;font:inherit;font-size:11px}.tbar .chip:hover{color:var(--ink)}.tbar svg{width:12px;height:12px}
276
+ .tgrid{position:relative;flex:1;min-height:0;display:grid;gap:6px}
277
+ .tile{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--line);border-radius:var(--r-s);background:var(--bg);overflow:hidden}.tile.off{display:none}.tile.drop{outline:2px solid var(--accent);outline-offset:-2px}
278
+ .tile header{display:flex;align-items:center;gap:8px;min-height:30px;padding:0 4px 0 10px;border-bottom:1px solid var(--line);background:var(--bg-2);cursor:grab;user-select:none}
279
+ .tile header b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600}.tile header .sp{flex:1}
280
+ .tile.ended .term{opacity:.6}.tend{flex:none;padding:0 6px;border-radius:9px;font-size:11px;color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}.tile header .btn.sm{height:22px;padding:0 8px;font-size:11px}
281
+ .tdot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--run)}.tdot.asking{background:var(--warn)}.tdot.ended{background:var(--dim);opacity:.6}
282
+ .tile .term{flex:1;min-height:0}.tile .term .xterm{height:100%;padding:4px}.tile .term .xterm-viewport{background:transparent!important}
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
+ body.tdrag{user-select:none}body.tdrag .tile{pointer-events:none}
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}}
259
288
  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}
260
289
  aside.docked>header,aside.docked>.body{padding-inline:28px}
261
290
  aside.docked>header{border-bottom:0;padding-top:28px;padding-bottom:12px}
@@ -311,6 +340,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
311
340
  .srow label{font-weight:400;font-size:15px;align-self:center}
312
341
  .srow>:nth-child(2){grid-column:2;grid-row:1;align-self:center;justify-self:end}
313
342
  .srow .hint{grid-column:1/-1;grid-row:2;margin:0;font-size:12px;color:var(--dim);line-height:1.45;max-width:46ch}
343
+ .srow.off>label,.srow.off>:nth-child(2){opacity:.5}
314
344
  .srow input.sel,.srow select.sel{width:170px;border:0;background-color:transparent;text-align:right;color:var(--dim);box-shadow:none}
315
345
  .srow select.sel{width:auto;text-align-last:right}
316
346
  .fpick{display:inline-flex;gap:6px;align-items:center}
@@ -326,6 +356,9 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
326
356
  /* a small number: one segment per value */
327
357
  .bar{display:inline-flex;padding:2px;gap:2px;background:var(--hover);border-radius:var(--r-s)}
328
358
  .bar button{min-width:32px;height:28px;border-radius:6px;font-size:13px;color:var(--ink);font-variant-numeric:tabular-nums}
359
+ .step{display:inline-flex;align-items:center;padding:2px;gap:2px;background:var(--hover);border-radius:var(--r-s)}
360
+ .step button{width:28px;height:28px;border-radius:6px;font-size:16px;color:var(--dim)}.step button:hover{background:var(--bg-2);color:var(--ink)}.step button:focus-visible{outline-offset:-2px;outline-color:var(--ink)}
361
+ .step output{min-width:88px;padding:0 6px;text-align:center;font-size:13px;color:var(--ink);white-space:nowrap}
329
362
  .bar button:hover{background:var(--hover);color:var(--ink)}.bar button.on,.bar button.on:hover{background:var(--bg-2);box-shadow:var(--shadow-1);font-weight:600}.bar button:focus-visible{outline-offset:-2px;outline-color:var(--ink)}
330
363
  #s-preset button{padding:0 12px;text-transform:capitalize}
331
364
  .toast{position:fixed;z-index:12;left:28px;bottom:56px;max-width:calc(100% - 56px);background:var(--ink);color:var(--bg);border-radius:var(--r);padding:10px 16px;font-size:13px;box-shadow:var(--shadow-2);opacity:0;transition:opacity .2s;pointer-events:none}
@@ -499,6 +532,8 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
499
532
  <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></symbol>
500
533
  <symbol id="i-ask" viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/><path d="M9.8 9.5a2.3 2.3 0 0 1 4.4.8c0 1.5-2.2 2-2.2 3.2M12 16.5h.01"/></symbol>
501
534
  <symbol id="i-term" viewBox="0 0 24 24"><path d="M4 17l6-5-6-5M12 19h8"/></symbol>
535
+ <symbol id="i-max" viewBox="0 0 24 24"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></symbol>
536
+ <symbol id="i-min" viewBox="0 0 24 24"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></symbol>
502
537
  <symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
503
538
  <symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="19" cy="12" r="1.2" fill="currentColor"/></symbol>
504
539
  <symbol id="i-open" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></symbol>
@@ -541,6 +576,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
541
576
  <a class="it" href="/?view=icebox" data-view="sources" title="Icebox: requests gathered from your sources"><svg><use href="#i-inbox"/></svg><span class="lbl">Icebox</span></a>
542
577
  <a class="it" href="/" data-view="board" title="Board"><svg><use href="#i-board"/></svg><span class="lbl">Board</span></a>
543
578
  <a class="it" href="/notebook" data-view="notes" title="Notes"><svg><use href="#i-note"/></svg><span class="lbl">Notes</span></a>
579
+ <a class="it" href="/?view=terminal" data-view="terminal" title="Terminal: every agent running in its card"><svg><use href="#i-term"/></svg><span class="lbl">Terminal</span></a>
544
580
  <a class="it" href="/?view=settings" data-view="settings" title="Settings"><svg><use href="#i-gear"/></svg><span class="lbl">Settings</span></a>
545
581
  <span class="end"></span>
546
582
  <div class="theme" role="group" aria-label="Theme"><button data-t="light" title="Light theme"><svg><use href="#i-sun"/></svg><span class="lbl">Light</span></button><button data-t="dark" title="Dark theme"><svg><use href="#i-moon"/></svg><span class="lbl">Dark</span></button></div>
@@ -567,12 +603,14 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
567
603
  <div class="combo" id="combo"><button class="sel" id="proj" aria-label="project" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" id="projpop" hidden><input class="sel" id="projq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="projs" role="listbox"></div></div></div>
568
604
  <div class="combo" id="showc" hidden><button class="sel" id="show" aria-label="Projects shown" aria-haspopup="true" aria-expanded="false"></button><div class="menu-pop" id="showpop" hidden><input class="sel" id="showq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="shows"></div><div class="pop-ft"><span id="showcount"></span><button id="showall">Show all</button></div></div></div>
569
605
  <button class="ib" id="fbtn" aria-label="Filter by status" title="Filter by status" aria-expanded="false" hidden><svg><use href="#i-filter"/></svg></button>
570
- <input type="search" class="sel" id="bq" placeholder="Filter cards /" aria-label="Filter cards" spellcheck="false" autocomplete="off">
606
+ <input type="search" class="sel" id="bq" placeholder="Filter cards (press /)" aria-label="Filter cards" spellcheck="false" autocomplete="off">
571
607
  <span class="sp"></span>
572
608
  <button class="btn pri" id="newtop">New idea</button>
573
609
  </div>
574
610
  <main id="board" aria-live="polite" aria-busy="true"><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section></main>
575
611
 
612
+ <div id="terms" aria-label="Terminals"></div>
613
+
576
614
  <div class="ftr"><span class="strip" id="strip"></span><span><a href="/issue" target="_blank" rel="noopener"><svg><use href="#i-bug"/></svg>Report an issue</a><a id="kbd"><svg><use href="#i-kbd"/></svg>Keyboard shortcuts</a></span></div>
577
615
 
578
616
  <div class="scrim" id="scrim"></div>