@monster-orange/den 0.4.11 → 0.4.13

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 (50) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +212 -12
  4. package/package.json +2 -1
  5. package/src/shared/agents/catalog.ts +77 -0
  6. package/src/shared/agents/default-pack/be-dev.md +1 -0
  7. package/src/shared/agents/default-pack/devops.md +1 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  10. package/src/shared/agents/default-pack/planner.md +1 -0
  11. package/src/shared/agents/default-pack/reviewer.md +1 -0
  12. package/src/shared/agents/default-pack/tester.md +16 -0
  13. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  14. package/src/shared/agents/den-claude.ts +151 -17
  15. package/src/shared/agents/permissions.ts +108 -0
  16. package/src/shared/agents/persona-store.ts +286 -0
  17. package/src/shared/agents/personas.ts +116 -12
  18. package/src/shared/agents/records.ts +57 -0
  19. package/src/shared/agents/roles.ts +288 -0
  20. package/src/shared/agents/runtime.ts +49 -0
  21. package/src/shared/agents/session-name.ts +110 -0
  22. package/src/shared/agents/transcript-follow.ts +180 -0
  23. package/src/shared/den-config.ts +27 -1
  24. package/src/ui/cli/agent-ops.ts +578 -11
  25. package/src/ui/cli/agents-command.ts +12 -2
  26. package/src/ui/cli/env-probe.ts +4 -1
  27. package/src/ui/mcp/tools.ts +1 -1
  28. package/src/ui/server/agent-paths.ts +52 -0
  29. package/src/ui/server/agent-routes.ts +329 -19
  30. package/src/ui/server/agents-layout-pref.ts +43 -0
  31. package/src/ui/server/editor-open-pref.ts +41 -0
  32. package/src/ui/server/index.ts +22 -0
  33. package/src/ui/server/public/agent-start.js +3 -2
  34. package/src/ui/server/public/agents-page.js +1037 -67
  35. package/src/ui/server/public/agents-section.js +1 -1
  36. package/src/ui/server/public/agents.html +236 -20
  37. package/src/ui/server/public/header.js +2 -0
  38. package/src/ui/server/public/i18n/es.js +139 -0
  39. package/src/ui/server/public/i18n/ja.js +139 -0
  40. package/src/ui/server/public/persona-ui.css +539 -0
  41. package/src/ui/server/public/persona-ui.js +796 -0
  42. package/src/ui/server/public/settings-page.js +4 -9
  43. package/src/ui/server/public/settings-registry.js +66 -0
  44. package/src/ui/server/public/settings.html +1 -0
  45. package/src/ui/server/public/shell.js +137 -0
  46. package/src/ui/server/public/term-input.js +129 -0
  47. package/src/ui/server/public/topbar.js +22 -4
  48. package/src/ui/server/route-classes.ts +29 -0
  49. package/src/ui/server/session-routes.ts +34 -1
  50. package/src/ui/server/ui-assets.ts +8 -0
@@ -27,8 +27,17 @@
27
27
  * Nothing here keeps a session's state: the list is GET /api/sessions,
28
28
  * asked again every few seconds while the page is shown.
29
29
  *
30
- * Start agent (AG-0.2): the runtime line says what will run, or why it
31
- * can't, before Start (agent-start.js readiness). Starting, stopping and
30
+ * Start agent (AG-10.1): a searchable persona list (category and source
31
+ * chips, grouped by category, Plain session pinned last), What's a
32
+ * persona?, Details, + New persona, Duplicate or Edit (persona-ui.js),
33
+ * Runtime and Workspace, a first instruction (typed into the session once
34
+ * it is ready, by the server: never an argument), and Advanced: model and
35
+ * version (AG-12), permission mode and extra allowed commands with the
36
+ * preview of what the runtime is given (AG-13). What differs from the
37
+ * persona tags the session's row (AG-10.9). The runtime line says what
38
+ * will run, or why it can't, before Start (agent-start.js readiness); a
39
+ * persona whose runtime isn't installed starts on the first one that is,
40
+ * and the dialog says so (AG-12.2). Starting, stopping and
32
41
  * resuming an agent never ask for the admin password: it is for sudo, and
33
42
  * no agent action runs sudo (LK-0).
34
43
  *
@@ -41,6 +50,30 @@
41
50
  * agent in view. After a restart, one line says how many were interrupted,
42
51
  * with Resume all and Review.
43
52
  *
53
+ * Layouts (AG-4b): Focus (the default: one pane; a click replaces it,
54
+ * Shift- or ⌘-click adds one alongside), Tabs (one pane under a strip of
55
+ * named tabs) or Tile (up to four in a grid: 1 full, 2 side by side, 3-4
56
+ * as 2x2). The page header's switch, ⌃⇧1 / ⌃⇧2 / ⌃⇧3 (Ctrl+Shift+digit
57
+ * on every OS: ⌘1-9 and Ctrl+1-9 are the browser's tabs) and ⌘K "Layout:
58
+ * …" pick one; it is the person's (POST /api/settings agents_layout) and the URL's (&layout=; a
59
+ * link's wins for that page and saves nothing). At phone width Focus and
60
+ * Tile are Tabs. Every pane open stays attached; a pane out of sight never
61
+ * resizes its PTY. Double-click a tab to rename the agent: the name is its
62
+ * record's (POST …/agents/<id>/name), shown in the list, the pane, ⌘K.
63
+ *
64
+ * The terminal (AG-4b): Shift+Enter sends the runtime's soft newline (its
65
+ * catalog softNewline, from /api/sessions: ESC CR, Codex's Ctrl+J; a
66
+ * plain session's is Enter) and nothing else. The layout keys never reach
67
+ * the agent. Links: plain URLs (addon-web-links) and OSC 8 links open on
68
+ * ⌘-click (Ctrl-click elsewhere) in a new tab with noopener, http and
69
+ * https only; a plain click selects as ever. A file path is a link when
70
+ * the server says it exists inside the agent's workspace (GET …/paths,
71
+ * stat only): ⌘-click opens it in Settings' editor_open (vscode, cursor,
72
+ * zed, by URL scheme, at the line), or, with none (the default), copies
73
+ * its absolute path. The session's name (its runtime's /rename, or Den's
74
+ * rename, whichever is later) follows the runtime in the pane header and
75
+ * the persona in its tab, when it says more than the agent's own name.
76
+ *
44
77
  * Session rotation (den#4): an agent whose session rotated (after
45
78
  * agents.rotate.after_compactions compactions, or its pull request merged)
46
79
  * and whose handoff.md passes the check reads "Ready to relaunch (3
@@ -50,7 +83,35 @@
50
83
  * and Start fresh. A running one says "Handoff due" or "Ready to relaunch"
51
84
  * as a tag.
52
85
  */
53
- import { fillStartForm } from "/agent-start.js";
86
+ import { readiness, runtimeName, runtimeOptions } from "/agent-start.js";
87
+ import {
88
+ commandProblem,
89
+ filterBarHtml,
90
+ modelOptionsHtml,
91
+ normalizeData,
92
+ openPersonaEditor,
93
+ overrideTagsHtml,
94
+ permBlockHtml,
95
+ personaDetail,
96
+ personaDetailHtml,
97
+ personaListHtml,
98
+ personaName,
99
+ personaRowHtml,
100
+ personaRuntime,
101
+ runtimeInstalled,
102
+ splitCommands,
103
+ syncPermBlock,
104
+ versionLabel,
105
+ versionOptionsHtml,
106
+ } from "/persona-ui.js";
107
+ import {
108
+ editorUrl,
109
+ findPaths,
110
+ isWebUrl,
111
+ layoutKeyIndex,
112
+ linkModifier,
113
+ softNewlineKey,
114
+ } from "/term-input.js";
54
115
 
55
116
  const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
56
117
  const esc = (s) =>
@@ -69,6 +130,13 @@ export const UNDO_MS = 10_000;
69
130
 
70
131
  /** The most panes at once (the prototype's 2x2). */
71
132
  export const MAX_PANES = 4;
133
+
134
+ /** The layouts (AG-4b), in the switch's order (⌃⇧1, ⌃⇧2, ⌃⇧3). */
135
+ export const LAYOUTS = ["focus", "tabs", "tile"];
136
+ /** At or below this width Focus and Tile are Tabs (the shell's phone width). */
137
+ const PHONE = "(max-width: 720px)";
138
+ /** The longest name a tab takes (records.ts MAX_DISPLAY_NAME). */
139
+ const MAX_NAME = 40;
72
140
  const REFRESH_MS = 5_000;
73
141
 
74
142
  const st = {
@@ -83,8 +151,37 @@ const st = {
83
151
  xterm: null,
84
152
  csrf: null,
85
153
  docWired: false,
154
+ // AG-4b: the person's layout, the pane in front, and in Focus the panes
155
+ // Shift-clicked in alongside it.
156
+ layout: "focus",
157
+ cur: null,
158
+ side: new Set(),
159
+ // AG-4b: where a path link opens (Settings editor_open).
160
+ editorOpen: "none",
86
161
  };
87
162
 
163
+ /** ?layout=, when it names one; else null. */
164
+ export function layoutFromUrl(search) {
165
+ const l = new URLSearchParams(search).get("layout");
166
+ return LAYOUTS.includes(l) ? l : null;
167
+ }
168
+
169
+ const phone = () => !!window.matchMedia?.(PHONE).matches;
170
+
171
+ /** The layout drawn: the person's, except at phone width (Tabs). */
172
+ function shownLayout() {
173
+ return phone() ? "tabs" : st.layout;
174
+ }
175
+
176
+ /** The panes in sight, in the URL's order. */
177
+ function visibleIds() {
178
+ const l = shownLayout();
179
+ if (l === "tile") return [...st.open];
180
+ if (!st.open.includes(st.cur)) st.cur = st.open.at(-1) ?? null;
181
+ if (l === "tabs") return st.cur ? [st.cur] : [];
182
+ return st.open.filter((id) => id === st.cur || st.side.has(id));
183
+ }
184
+
88
185
  // ── the URL ─────────────────────────────────────────────────────────────
89
186
 
90
187
  /** The ids ?open= names (at most MAX_PANES, in order, no repeats). */
@@ -101,6 +198,7 @@ function writeUrl() {
101
198
  const u = new URL(location.href);
102
199
  if (st.open.length) u.searchParams.set("open", st.open.join(","));
103
200
  else u.searchParams.delete("open");
201
+ u.searchParams.set("layout", st.layout);
104
202
  u.searchParams.delete("start");
105
203
  const s = u.searchParams.toString().replace(/%2C/gi, ",");
106
204
  history.replaceState(history.state, "", u.pathname + (s ? `?${s}` : ""));
@@ -147,7 +245,34 @@ function stateText(state) {
147
245
  }
148
246
 
149
247
  function displayName(r) {
150
- return r.kind === "agent" ? (r.persona ?? r.label) : r.label;
248
+ if (r.kind !== "agent") return r.label;
249
+ return r.displayName || (r.persona ?? r.label);
250
+ }
251
+
252
+ /**
253
+ * A row's name: the name a person gave it (AG-4b), then its persona and
254
+ * id; else its persona, then its id (when they differ).
255
+ */
256
+ function nameHtml(r) {
257
+ const extra = sessionExtra(r);
258
+ if (r.displayName)
259
+ return `${esc(r.displayName)} <span class="id">${esc([r.persona, r.label, extra].filter(Boolean).join(" · "))}</span>`;
260
+ const id = [r.persona && r.persona !== r.label ? r.label : "", extra]
261
+ .filter(Boolean)
262
+ .join(" · ");
263
+ return `${esc(displayName(r))}${id ? ` <span class="id">${esc(id)}</span>` : ""}`;
264
+ }
265
+
266
+ /**
267
+ * The session's name (AG-4b: the runtime's /rename or Den's, the later),
268
+ * where it says more than the agent's own name: not empty, not the
269
+ * persona, not the name already shown. Else "".
270
+ */
271
+ function sessionExtra(r) {
272
+ const n = r?.kind === "agent" ? (r.sessionTitle ?? "").trim() : "";
273
+ if (!n || n === r.persona || n === paneName(r) || n === displayName(r))
274
+ return "";
275
+ return n;
151
276
  }
152
277
 
153
278
  // A pane's title and its Close: the agent's display name (AG-10.8), else
@@ -211,7 +336,7 @@ function rotationOf(r) {
211
336
  function stoppedHtml(r) {
212
337
  const open = st.open.includes(r.id);
213
338
  const name = displayName(r);
214
- const nm = `${esc(name)}${r.persona && r.persona !== r.label ? ` <span class="id">${esc(r.label)}</span>` : ""}`;
339
+ const nm = nameHtml(r);
215
340
  const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
216
341
  const who = esc(r.agent);
217
342
  const rot = rotationOf(r);
@@ -264,7 +389,7 @@ function rowHtml(r) {
264
389
  const open = st.open.includes(r.id);
265
390
  const nm =
266
391
  r.kind === "agent"
267
- ? `${esc(displayName(r))}${r.persona && r.persona !== r.label ? ` <span class="id">${esc(r.label)}</span>` : ""}`
392
+ ? nameHtml(r)
268
393
  : `${esc(r.label)} <span class="sbdg">${esc(t("ag.session"))}</span>`;
269
394
  const right = r.waiting
270
395
  ? `<span class="wbdg">${esc(t("ag.waiting"))}</span>`
@@ -281,15 +406,16 @@ function rowHtml(r) {
281
406
  const revokedTag = r.tokenRevoked
282
407
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
283
408
  : "";
409
+ const ovTags = r.kind === "agent" ? overrideTagsHtml(r) : "";
284
410
  const tags =
285
- rotTag || revokedTag
286
- ? `<span class="agtags">${revokedTag}${rotTag}</span>`
411
+ rotTag || revokedTag || ovTags
412
+ ? `<span class="agtags">${revokedTag}${rotTag}${ovTags}</span>`
287
413
  : "";
288
414
  return (
289
415
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
290
416
  ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
291
417
  ` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${displayName(r)}` }))}">` +
292
- `<span class="den-dot ${stateTone(r)}" aria-hidden="true"></span>` +
418
+ `<span class="den-dot ${r.waiting ? "warn" : stateTone(r)}" aria-hidden="true"></span>` +
293
419
  `<span class="nm">${nm}</span>${right}` +
294
420
  (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
295
421
  tags +
@@ -360,7 +486,10 @@ function paintList() {
360
486
  )
361
487
  .join("")
362
488
  : `<div class="none">${esc(t("ag.none.scope", { scope: st.scopeLabel }))}</div>`;
363
- el.innerHTML = notes.join("") + body;
489
+ el.innerHTML =
490
+ notes.join("") +
491
+ body +
492
+ `<div class="aghint" id="agl-hint">${esc(t(`ag.hint.${shownLayout()}`))} ${esc(t("ag.hint.keys"))}</div>`;
364
493
  const clear = st.root.querySelector("#ag-clear");
365
494
  if (clear) clear.hidden = !visibleRows().some(isStopped);
366
495
  // No such workspace: nothing to start an agent in.
@@ -388,6 +517,21 @@ function paintSub() {
388
517
  if (url) url.textContent = location.pathname + location.search;
389
518
  }
390
519
 
520
+ /**
521
+ * The person's layout (GET /api/settings agents_layout), else Focus; and
522
+ * where a path link opens (editor_open), else none.
523
+ */
524
+ async function savedLayout() {
525
+ try {
526
+ const r = await fetch("/api/settings", { headers: { accept: "json" } });
527
+ const b = r.ok ? await r.json() : null;
528
+ if (typeof b?.editor_open === "string") st.editorOpen = b.editor_open;
529
+ return LAYOUTS.includes(b?.agents_layout) ? b.agents_layout : "focus";
530
+ } catch {
531
+ return "focus";
532
+ }
533
+ }
534
+
391
535
  async function loadScope() {
392
536
  try {
393
537
  const r = await fetch("/api/scope");
@@ -425,6 +569,8 @@ async function refresh() {
425
569
  paintList();
426
570
  paintSub();
427
571
  for (const p of st.panes.values()) paintBar(p);
572
+ const strip = st.root.querySelector("#agr > .agtabs");
573
+ if (strip) paintTabs(strip);
428
574
  if (!st.root.isConnected) return;
429
575
  st.timer = setTimeout(() => {
430
576
  if (!document.hidden) void refresh();
@@ -440,7 +586,12 @@ async function loadXterm() {
440
586
  st.xterm ??= Promise.all([
441
587
  import("/vendor/xterm.mjs"),
442
588
  import("/vendor/addon-fit.mjs"),
443
- ]).then(([x, f]) => ({ Terminal: x.Terminal, FitAddon: f.FitAddon }));
589
+ import("/vendor/addon-web-links.mjs"),
590
+ ]).then(([x, f, w]) => ({
591
+ Terminal: x.Terminal,
592
+ FitAddon: f.FitAddon,
593
+ WebLinksAddon: w.WebLinksAddon,
594
+ }));
444
595
  return st.xterm;
445
596
  }
446
597
 
@@ -479,30 +630,233 @@ function termTheme() {
479
630
  return theme;
480
631
  }
481
632
 
633
+ /** A tab's words: the agent's name, then its persona and workspace. */
634
+ function tabWords(id) {
635
+ const r = rowOf(id);
636
+ const p = st.panes.get(id);
637
+ if (!r) return { name: p?.label || id, meta: p?.workspace || "" };
638
+ if (r.kind !== "agent")
639
+ return {
640
+ name: r.label,
641
+ meta: [t("ag.session"), r.workspace].join(" · "),
642
+ };
643
+ return {
644
+ name: paneName(r),
645
+ meta: [r.persona, r.workspace, sessionExtra(r)].filter(Boolean).join(" · "),
646
+ };
647
+ }
648
+
649
+ /** The tab strip (AG-4b Tabs): one tab per open pane, its × beside it. */
650
+ function paintTabs(strip) {
651
+ // A tab being renamed keeps its input.
652
+ if (strip.querySelector("input")) return;
653
+ const have = [...strip.querySelectorAll(":scope > .agtab-w")].map(
654
+ (w) => w.dataset.id,
655
+ );
656
+ // The same tabs: changed in place, so a double-click's target stays.
657
+ if (have.join("\n") === st.open.join("\n")) {
658
+ for (const w of strip.querySelectorAll(":scope > .agtab-w")) {
659
+ const id = w.dataset.id;
660
+ const r = rowOf(id);
661
+ const words = tabWords(id);
662
+ const on = id === st.cur;
663
+ const tab = w.querySelector('[role="tab"]');
664
+ tab.setAttribute("aria-selected", String(on));
665
+ tab.tabIndex = on ? 0 : -1;
666
+ tab.querySelector(".den-dot").className =
667
+ `den-dot ${r?.waiting ? "warn" : r ? stateTone(r) : "pending"}`;
668
+ tab.querySelector(".tn").textContent = words.name;
669
+ tab.querySelector(".tm").textContent = words.meta;
670
+ const sr = tab.querySelector(".den-sr");
671
+ if (sr) sr.hidden = !r?.waiting;
672
+ w.querySelector(".agtab-x").setAttribute(
673
+ "aria-label",
674
+ t("ag.detachName", { id: words.name }),
675
+ );
676
+ }
677
+ return;
678
+ }
679
+ strip.innerHTML = st.open
680
+ .map((id) => {
681
+ const r = rowOf(id);
682
+ const w = tabWords(id);
683
+ const on = id === st.cur;
684
+ const tone = r?.waiting ? "warn" : r ? stateTone(r) : "pending";
685
+ const rename =
686
+ r?.kind === "agent" ? ` title="${esc(t("ag.renameTip"))}"` : "";
687
+ return (
688
+ `<span class="agtab-w" data-id="${esc(id)}">` +
689
+ `<button type="button" role="tab" class="agtab" data-id="${esc(id)}" aria-selected="${on}" tabindex="${on ? 0 : -1}"${rename}>` +
690
+ `<span class="den-dot ${tone}" aria-hidden="true"></span>` +
691
+ `<span class="den-sr"${r?.waiting ? "" : " hidden"}>${esc(t("ag.waiting"))}: </span>` +
692
+ `<span class="tn">${esc(w.name)}</span><span class="tm mono">${esc(w.meta)}</span></button>` +
693
+ `<button type="button" class="agtab-x" data-act="close" data-id="${esc(id)}" aria-label="${esc(t("ag.detachName", { id: w.name }))}" title="${esc(t("ag.detach"))}">×</button>` +
694
+ `</span>`
695
+ );
696
+ })
697
+ .join("");
698
+ }
699
+
482
700
  function paintLayout() {
483
701
  const box = st.root.querySelector("#agr");
484
702
  const n = st.open.length;
703
+ paintSwitch();
485
704
  if (n === 0) {
486
705
  box.className = "agempty";
706
+ delete box.dataset.layout;
487
707
  box.innerHTML = `<div>${esc(t("ag.empty"))}</div>`;
488
708
  return;
489
709
  }
490
- box.className = `agp n${n}`;
491
- const empty = box.querySelector(":scope > div:not(.pane)");
710
+ const layout = shownLayout();
711
+ const shown = visibleIds();
712
+ box.className = `agp n${Math.max(1, shown.length)}${layout === "tabs" ? " tabs" : ""}`;
713
+ box.dataset.layout = layout;
714
+ const empty = box.querySelector(":scope > div:not(.pane):not(.agtabs)");
492
715
  if (empty) empty.remove();
493
- // Panes in the URL's order.
716
+ let strip = box.querySelector(":scope > .agtabs");
717
+ if (layout === "tabs") {
718
+ if (!strip) {
719
+ strip = document.createElement("div");
720
+ strip.className = "agtabs";
721
+ strip.setAttribute("role", "tablist");
722
+ strip.setAttribute("aria-label", t("ag.tabs"));
723
+ }
724
+ box.prepend(strip);
725
+ paintTabs(strip);
726
+ // The tab in front is in sight in the strip (it scrolls sideways;
727
+ // the page itself never scrolls for it).
728
+ const on = strip.querySelector('[role="tab"][aria-selected="true"]');
729
+ const w = on?.parentElement;
730
+ if (w && strip.clientWidth > 0) {
731
+ const left = w.offsetLeft - strip.offsetLeft;
732
+ if (left < strip.scrollLeft) strip.scrollLeft = left;
733
+ else if (left + w.offsetWidth > strip.scrollLeft + strip.clientWidth)
734
+ strip.scrollLeft = left + w.offsetWidth - strip.clientWidth;
735
+ }
736
+ } else strip?.remove();
737
+ // Panes in the URL's order; those out of sight stay attached, hidden.
494
738
  for (const id of st.open) {
495
739
  const p = st.panes.get(id);
496
- if (p) box.appendChild(p.el);
740
+ if (!p) continue;
741
+ p.el.hidden = !shown.includes(id);
742
+ p.el.classList.toggle("in-tabs", layout === "tabs");
743
+ const x = p.el.querySelector('.bar2 [data-act="close"]');
744
+ if (x) x.hidden = layout === "tabs";
745
+ box.appendChild(p.el);
746
+ }
747
+ // Each pane in sight fits its new box (a hidden one is never measured).
748
+ requestAnimationFrame(() => {
749
+ for (const id of shown) {
750
+ const p = st.panes.get(id);
751
+ if (p) refit(p);
752
+ }
753
+ });
754
+ }
755
+
756
+ /** The header's switch: the person's layout pressed (AG-4b). */
757
+ function paintSwitch() {
758
+ for (const b of st.root.querySelectorAll("[data-layouts] button"))
759
+ b.setAttribute("aria-pressed", String(b.dataset.layout === st.layout));
760
+ }
761
+
762
+ /**
763
+ * Switch the layout (the header's switch, ⌘1-3): drawn at once, the URL
764
+ * follows, and it is saved for the person. Focus shows the pane in front.
765
+ */
766
+ function setLayout(layout) {
767
+ if (!LAYOUTS.includes(layout)) return;
768
+ const was = st.layout;
769
+ st.layout = layout;
770
+ st.side.clear();
771
+ paintLayout();
772
+ paintList();
773
+ writeUrl();
774
+ if (layout === was) return;
775
+ api("POST", "/api/settings", { agents_layout: layout }).catch((err) =>
776
+ window.denShell?.errorToast?.(err),
777
+ );
778
+ }
779
+
780
+ /**
781
+ * Open (or bring forward) the session of a row the person clicked, as the
782
+ * layout says: Focus replaces the pane (`add`: Shift- or ⌘-click, one
783
+ * alongside), Tabs opens a tab, Tile adds a tile.
784
+ */
785
+ async function pick(id, add) {
786
+ const layout = shownLayout();
787
+ if (layout === "focus" && !add) {
788
+ st.side.clear();
789
+ for (const other of [...st.open])
790
+ if (other !== id) closePane(other, { quiet: true });
791
+ } else if (layout === "focus" && add && st.cur && st.cur !== id)
792
+ st.side.add(id);
793
+ if (!(layout === "focus" && add)) st.cur = id;
794
+ if (st.panes.has(id)) {
795
+ paintLayout();
796
+ paintList();
797
+ st.panes.get(id).term?.focus();
798
+ return;
497
799
  }
800
+ await openPane(id, { front: !(layout === "focus" && add) });
801
+ }
802
+
803
+ /** Double-click a tab: its name becomes an input (Enter saves, Esc doesn't). */
804
+ function renameTab(tab) {
805
+ const id = tab.dataset.id;
806
+ const r = rowOf(id);
807
+ if (r?.kind !== "agent" || tab.querySelector("input")) return;
808
+ const input = document.createElement("input");
809
+ input.type = "text";
810
+ input.className = "agtab-in";
811
+ input.maxLength = MAX_NAME;
812
+ input.value = r.displayName || r.agent;
813
+ input.setAttribute("aria-label", t("ag.renameName", { id: r.agent }));
814
+ // Beside the tab, not in it (a button holds no input): the tab hides.
815
+ tab.hidden = true;
816
+ tab.before(input);
817
+ input.focus();
818
+ input.select();
819
+ let done = false;
820
+ const finish = async (save) => {
821
+ if (done) return;
822
+ done = true;
823
+ const name = input.value.trim();
824
+ input.remove();
825
+ tab.hidden = false;
826
+ if (save && name !== (r.displayName || r.agent)) {
827
+ try {
828
+ await api("POST", agentPath(r, "name"), {
829
+ name: name && name !== r.agent ? name : null,
830
+ });
831
+ } catch (err) {
832
+ window.denShell?.errorToast?.(err);
833
+ }
834
+ await refresh();
835
+ }
836
+ paintLayout();
837
+ };
838
+ input.addEventListener("keydown", (e) => {
839
+ e.stopPropagation();
840
+ if (e.key === "Enter") {
841
+ e.preventDefault();
842
+ void finish(true);
843
+ } else if (e.key === "Escape") {
844
+ e.preventDefault();
845
+ void finish(false);
846
+ }
847
+ });
848
+ input.addEventListener("blur", () => void finish(true));
849
+ // A click in the input is the input's, not the tab's.
850
+ input.addEventListener("click", (e) => e.stopPropagation());
498
851
  }
499
852
 
500
853
  function paintBar(p) {
501
854
  const r = rowOf(p.id);
502
855
  const bar = p.el.querySelector(".bar2");
503
856
  const name = r ? paneName(r) : p.label || p.id;
857
+ // AG-4b: the session's name last, when it says more.
504
858
  const where = r
505
- ? [r.workspace, r.runtime].filter(Boolean).join(" · ")
859
+ ? [r.workspace, r.runtime, sessionExtra(r)].filter(Boolean).join(" · ")
506
860
  : p.workspace || "";
507
861
  const tone = r ? stateTone(r) : p.exited ? "off" : "pending";
508
862
  const stop =
@@ -524,6 +878,9 @@ function paintBar(p) {
524
878
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
525
879
  : "") +
526
880
  `<span class="x">${reconnect}${stop}${resume}<button type="button" class="den-btn sm" data-act="close" aria-label="${esc(t("ag.detachName", { id: name }))}" title="${esc(t("ag.detach"))}">×</button></span>`;
881
+ // Tabs: the tab's × closes it; the pane's bar has none.
882
+ const x = bar.querySelector('[data-act="close"]');
883
+ if (x) x.hidden = shownLayout() === "tabs";
527
884
  }
528
885
 
529
886
  function send(p, obj) {
@@ -556,11 +913,26 @@ function refit(p) {
556
913
  markSize(p);
557
914
  }
558
915
 
559
- /** The pane's size and the PTY's, on the pane (data-cols, data-pty-cols). */
916
+ /**
917
+ * The pane's size and the PTY's, on the pane (data-cols, data-pty-cols),
918
+ * and the cell size xterm drew with (data-cell-w, data-cell-h: its
919
+ * renderer's CSS cell, sub-pixel), so a check can tell a missed row from
920
+ * a fit's remainder.
921
+ */
560
922
  function markSize(p) {
561
923
  if (!p.term) return;
562
924
  p.el.dataset.cols = String(p.term.cols);
563
925
  p.el.dataset.rows = String(p.term.rows);
926
+ let cell = null;
927
+ try {
928
+ cell = p.term._core?._renderService?.dimensions?.css?.cell ?? null;
929
+ } catch {
930
+ cell = null;
931
+ }
932
+ if (cell?.width > 0 && cell?.height > 0) {
933
+ p.el.dataset.cellW = String(cell.width);
934
+ p.el.dataset.cellH = String(cell.height);
935
+ }
564
936
  if (p.pty) {
565
937
  p.el.dataset.ptyCols = String(p.pty.cols);
566
938
  p.el.dataset.ptyRows = String(p.pty.rows);
@@ -695,11 +1067,111 @@ async function connect(p) {
695
1067
  };
696
1068
  }
697
1069
 
698
- async function openPane(id) {
1070
+ // ── links and keys in a pane (AG-4b) ───────────────────────────────────
1071
+
1072
+ /** What Shift+Enter sends to a pane's session (a plain session: Enter). */
1073
+ function softNewlineOf(id) {
1074
+ const seq = rowOf(id)?.softNewline;
1075
+ return typeof seq === "string" && seq ? seq : "\r";
1076
+ }
1077
+
1078
+ /**
1079
+ * A URL or an OSC 8 link clicked: with ⌘ (Ctrl elsewhere), an http or
1080
+ * https one opens in a new tab, noopener. A plain click opens nothing
1081
+ * (it selects); every other scheme (javascript:, data:, file:) nothing.
1082
+ */
1083
+ function openWeb(e, uri) {
1084
+ if (!linkModifier(e) || !isWebUrl(uri)) return;
1085
+ e?.preventDefault?.();
1086
+ window.open(uri, "_blank", "noopener");
1087
+ }
1088
+
1089
+ /** The most paths asked about at once (agent-paths.ts MAX_PATHS). */
1090
+ const MAX_ASK = 64;
1091
+
1092
+ /**
1093
+ * Which of `paths` are links for agent row `r`: absolute path or null,
1094
+ * each asked once per pane (GET …/agents/<id>/paths: the server resolves
1095
+ * them against its cwd, inside its workspace; stat only).
1096
+ */
1097
+ async function pathLinks(p, r, paths) {
1098
+ p.paths ??= new Map();
1099
+ const ask = [...new Set(paths)].filter((x) => !p.paths.has(x));
1100
+ for (let i = 0; i < ask.length; i += MAX_ASK) {
1101
+ const batch = ask.slice(i, i + MAX_ASK);
1102
+ const q = batch.map((x) => `p=${encodeURIComponent(x)}`).join("&");
1103
+ const got = fetch(`${agentPath(r, "paths")}?${q}`, {
1104
+ headers: { accept: "json" },
1105
+ })
1106
+ .then((res) => (res.ok ? res.json() : null))
1107
+ .catch(() => null);
1108
+ for (const x of batch)
1109
+ p.paths.set(
1110
+ x,
1111
+ got.then((b) => {
1112
+ const v = b?.paths?.[x];
1113
+ return typeof v === "string" ? v : null;
1114
+ }),
1115
+ );
1116
+ }
1117
+ const out = new Map();
1118
+ for (const x of paths) out.set(x, await p.paths.get(x));
1119
+ return out;
1120
+ }
1121
+
1122
+ /** xterm asks for line `y`'s links (1-based): its paths that exist. */
1123
+ async function providePathLinks(p, y, done) {
1124
+ try {
1125
+ const r = rowOf(p.id);
1126
+ const line = p.term?.buffer.active.getLine(y - 1)?.translateToString(true);
1127
+ if (r?.kind !== "agent" || !r.agent || !line) return done(undefined);
1128
+ const found = findPaths(line);
1129
+ if (!found.length) return done(undefined);
1130
+ const abs = await pathLinks(
1131
+ p,
1132
+ r,
1133
+ found.map((m) => m.path),
1134
+ );
1135
+ const links = found
1136
+ .filter((m) => abs.get(m.path))
1137
+ .map((m) => ({
1138
+ text: m.text,
1139
+ range: { start: { x: m.start + 1, y }, end: { x: m.end, y } },
1140
+ decorations: { pointerCursor: true, underline: true },
1141
+ activate: (e) => void openPath(e, abs.get(m.path), m.line, m.col),
1142
+ }));
1143
+ done(links.length ? links : undefined);
1144
+ } catch {
1145
+ done(undefined);
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * A path link clicked: with ⌘ (Ctrl elsewhere), the editor in Settings
1151
+ * (editor_open) at the line, by its URL scheme; none copies the path.
1152
+ */
1153
+ async function openPath(e, abs, line, col) {
1154
+ if (!linkModifier(e) || !abs) return;
1155
+ e?.preventDefault?.();
1156
+ const url = editorUrl(st.editorOpen, abs, line, col);
1157
+ if (url) {
1158
+ window.open(url, "_self");
1159
+ return;
1160
+ }
1161
+ try {
1162
+ await navigator.clipboard.writeText(abs);
1163
+ window.denShell?.toast?.(t("ag.copied"));
1164
+ } catch (err) {
1165
+ window.denShell?.errorToast?.(err);
1166
+ }
1167
+ }
1168
+
1169
+ async function openPane(id, { front = true } = {}) {
699
1170
  if (st.panes.has(id)) {
700
1171
  st.panes.get(id).term?.focus();
701
1172
  return;
702
1173
  }
1174
+ if (front) st.cur = id;
703
1175
  if (st.open.length >= MAX_PANES) {
704
1176
  const oldest = st.open[0];
705
1177
  window.denShell?.toast?.(
@@ -710,7 +1182,10 @@ async function openPane(id) {
710
1182
  const el = document.createElement("section");
711
1183
  el.className = "pane";
712
1184
  el.dataset.id = id;
713
- el.innerHTML = `<div class="bar2"></div><div class="term"></div>`;
1185
+ // xterm opens in .tbox, the .term's content box: the fit measures its
1186
+ // parent's height and width as they are, so the .term's padding must
1187
+ // not be in it (AG-4b: every terminal fits its pane).
1188
+ el.innerHTML = `<div class="bar2"></div><div class="term"><div class="tbox"></div></div>`;
714
1189
  const p = {
715
1190
  id,
716
1191
  el,
@@ -745,18 +1220,50 @@ async function openPane(id) {
745
1220
  scrollback: 5000,
746
1221
  theme: termTheme(),
747
1222
  allowProposedApi: false,
1223
+ // OSC 8 links (AG-4b): ⌘/Ctrl-click, http and https only, no confirm.
1224
+ linkHandler: {
1225
+ activate: (e, uri) => void openWeb(e, uri),
1226
+ allowNonHttpProtocols: false,
1227
+ },
748
1228
  });
749
1229
  const fit = new lib.FitAddon();
750
1230
  term.loadAddon(fit);
751
- term.open(el.querySelector(".term"));
1231
+ // Plain URLs (AG-4b): the same rule as OSC 8.
1232
+ term.loadAddon(new lib.WebLinksAddon((e, uri) => void openWeb(e, uri)));
1233
+ term.open(el.querySelector(".term .tbox"));
752
1234
  p.term = term;
753
1235
  p.fit = fit;
754
1236
  const enc = new TextEncoder();
755
- term.onData((d) => {
1237
+ const typeIn = (d) => {
756
1238
  if (p.sock?.readyState !== WebSocket.OPEN) return;
757
1239
  // The one typing wins: another viewer resized the PTY since.
758
1240
  if (outOfStep(p)) claimSize(p);
759
1241
  p.sock.send(enc.encode(d));
1242
+ };
1243
+ term.onData(typeIn);
1244
+ // AG-4b: Shift+Enter is the runtime's soft newline, never a CR; the
1245
+ // layout keys (⌃⇧1-3) are the page's, never the agent's.
1246
+ term.attachCustomKeyEventHandler((e) => {
1247
+ if (e.ctrlKey && e.shiftKey && /^Digit[123]$/.test(e.code ?? ""))
1248
+ return false;
1249
+ if (
1250
+ e.key === "Enter" &&
1251
+ e.shiftKey &&
1252
+ !e.ctrlKey &&
1253
+ !e.altKey &&
1254
+ !e.metaKey
1255
+ ) {
1256
+ if (e.isComposing) return true;
1257
+ e.preventDefault();
1258
+ if (softNewlineKey(e)) typeIn(softNewlineOf(id));
1259
+ return false;
1260
+ }
1261
+ return true;
1262
+ });
1263
+ // File paths (AG-4b): links only where the server says they exist in
1264
+ // the agent's workspace.
1265
+ term.registerLinkProvider({
1266
+ provideLinks: (y, done) => void providePathLinks(p, y, done),
760
1267
  });
761
1268
  term.onResize(() => sendSize(p));
762
1269
  // Focused (a click, the window coming back): this pane's size.
@@ -777,6 +1284,8 @@ function closePane(id, { quiet = false } = {}) {
777
1284
  p.closed = true;
778
1285
  st.panes.delete(id);
779
1286
  st.open = st.open.filter((x) => x !== id);
1287
+ st.side.delete(id);
1288
+ if (st.cur === id) st.cur = st.open.at(-1) ?? null;
780
1289
  try {
781
1290
  p.sock?.close(1000, "closed");
782
1291
  } catch {
@@ -984,7 +1493,7 @@ function review() {
984
1493
  )?.focus();
985
1494
  }
986
1495
 
987
- // ── Start agent ─────────────────────────────────────────────────────────
1496
+ // ── Start agent (AG-10.1) ───────────────────────────────────────────────
988
1497
 
989
1498
  // Personas and runtimes, per workspace (AG-0.5: a workspace's own runtime
990
1499
  // paths win), asked once per page each.
@@ -1006,79 +1515,478 @@ function personasFor(ws) {
1006
1515
  return personasAsked.get(key);
1007
1516
  }
1008
1517
 
1009
- /** Fill the form for the workspace picked; false when the ask failed. */
1518
+ /**
1519
+ * "Plain session": no persona, a shell in the workspace (AG-10.1). A ":"
1520
+ * is never in a persona's id.
1521
+ */
1522
+ const PLAIN = ":plain";
1523
+
1524
+ /** The dialog's state while it is open. */
1525
+ const sd = {
1526
+ data: null,
1527
+ f: { q: "", cat: "", src: "" },
1528
+ pid: "",
1529
+ det: false,
1530
+ what: false,
1531
+ detail: null,
1532
+ // Advanced: "" = the persona's.
1533
+ model: "",
1534
+ ver: "",
1535
+ intent: "ask",
1536
+ extras: [],
1537
+ extrasOpen: false,
1538
+ };
1539
+
1540
+ const $d = (sel) => st.root.querySelector(sel);
1541
+ const curPersona = () =>
1542
+ sd.pid === PLAIN
1543
+ ? null
1544
+ : (sd.data?.personas ?? []).find((p) => p.id === sd.pid) || null;
1545
+
1546
+ /** The runtime the dialog will start: the one picked, else the persona's. */
1547
+ function chosenRuntime() {
1548
+ const p = curPersona();
1549
+ return $d("#ag-runtime").value || personaRuntime(sd.data, p);
1550
+ }
1551
+
1552
+ /** The persona's own model and version, on the runtime chosen (AG-12.3). */
1553
+ function personaModel() {
1554
+ const p = curPersona();
1555
+ const same = p && personaRuntime(sd.data, p) === chosenRuntime();
1556
+ return {
1557
+ model: same ? p.model || "" : "",
1558
+ version: same ? p.version || "latest" : "latest",
1559
+ };
1560
+ }
1561
+
1562
+ /** The persona list's rows, and Plain session pinned at the bottom. */
1563
+ function plistHtml() {
1564
+ const ps = sd.data?.personas ?? [];
1565
+ const cats = sd.data?.categories ?? [];
1566
+ return (
1567
+ personaListHtml(sd.f, ps, cats, (p) =>
1568
+ personaRowHtml(p, p.id === sd.pid, sd.data),
1569
+ ) +
1570
+ `<div class="pgh"><span>${esc(t("ag.session"))}</span><span></span></div>` +
1571
+ `<button type="button" class="pr" role="radio" data-pid="${esc(PLAIN)}" aria-checked="${sd.pid === PLAIN}"><span class="rd" aria-hidden="true"></span><span class="pn">${esc(t("ag.new.plain"))}</span><span></span><span class="pd">${esc(t("ag.new.plainP"))}</span></button>`
1572
+ );
1573
+ }
1574
+
1575
+ function paintPlist(keepScroll = true) {
1576
+ const l = $d("#ag-persona");
1577
+ const top = l.scrollTop;
1578
+ l.innerHTML = plistHtml();
1579
+ l.scrollTop = keepScroll ? top : 0;
1580
+ }
1581
+
1582
+ /** The Details row (and Duplicate or Edit) under the list, and Details. */
1583
+ function paintDetails() {
1584
+ const p = curPersona();
1585
+ const el = $d("#ag-detrow");
1586
+ if (!p) {
1587
+ el.innerHTML = "";
1588
+ return;
1589
+ }
1590
+ const mine = p.origin === "local";
1591
+ el.innerHTML =
1592
+ `<div class="fldh"><button type="button" class="lnk" data-x="det" aria-expanded="${sd.det}" aria-controls="ag-det">${esc(sd.det ? t("ag.new.hide") : t("ag.new.details", { n: personaName(p) }))}</button><span class="sp"></span>` +
1593
+ (mine
1594
+ ? `<button type="button" class="lnk" data-x="edit">${esc(t("pe.editB"))}</button>`
1595
+ : "") +
1596
+ `<button type="button" class="lnk" data-x="dup">${esc(t("pe.dup"))}</button></div>` +
1597
+ (sd.det
1598
+ ? personaDetailHtml(
1599
+ sd.detail?.id === p.id ? sd.detail : { ...p, instructions: null },
1600
+ sd.data,
1601
+ )
1602
+ : "");
1603
+ }
1604
+
1605
+ /**
1606
+ * The runtime select and its readiness line (AG-0.2), and AG-12.2: a
1607
+ * persona whose runtime isn't installed starts on the first one that is,
1608
+ * and the dialog says so.
1609
+ */
1610
+ function paintRuntime(reset) {
1611
+ const rs = $d("#ag-runtime");
1612
+ const go = $d("#ag-go");
1613
+ const ready = $d("#ag-r-ready");
1614
+ const plain = sd.pid === PLAIN;
1615
+ $d("#ag-r-l").hidden = plain;
1616
+ $d("#ag-task-l").hidden = plain;
1617
+ $d("#ag-adv").hidden = plain;
1618
+ const fb = $d("#ag-r-fb");
1619
+ fb.hidden = true;
1620
+ if (plain) {
1621
+ $d("#ag-r-miss").hidden = true;
1622
+ ready.hidden = true;
1623
+ go.disabled = false;
1624
+ go.dataset.blocked = "";
1625
+ return;
1626
+ }
1627
+ const personas = sd.data?.personas ?? [];
1628
+ const runtimes = sd.data?.runtimes ?? [];
1629
+ const keep = reset ? "" : rs.value;
1630
+ rs.innerHTML = runtimeOptions(personas, runtimes, sd.pid, t);
1631
+ if ([...rs.options].some((o) => o.value === keep && !o.disabled))
1632
+ rs.value = keep;
1633
+ const p = curPersona();
1634
+ if (reset && p && !runtimeInstalled(sd.data, p)) {
1635
+ const first = [...rs.options].find((o) => o.value && !o.disabled);
1636
+ if (first) {
1637
+ rs.value = first.value;
1638
+ fb.textContent = t("rt.fallback", {
1639
+ p: personaName(p),
1640
+ r: runtimeName(runtimes, personaRuntime(sd.data, p)),
1641
+ f: runtimeName(runtimes, first.value),
1642
+ });
1643
+ fb.hidden = false;
1644
+ }
1645
+ }
1646
+ // AG-12.1: what isn't installed here, and Install… (later).
1647
+ const miss = runtimes.filter(
1648
+ (x) => !x.available && x.denTools !== undefined && x.source !== "setting",
1649
+ );
1650
+ const ml = $d("#ag-r-miss");
1651
+ ml.innerHTML = miss.length
1652
+ ? `${esc(t("rt.missingLine"))} ${miss.map((x) => `<span class="mono">${esc(x.label || x.id)}</span>`).join(", ")} · <span class="lnk" aria-disabled="true" title="${esc(t("rt.later"))}">${esc(t("rt.install"))}</span> <span class="tagx">${esc(t("rt.later"))}</span>`
1653
+ : "";
1654
+ ml.hidden = !miss.length;
1655
+ const r = readiness(sd.data, sd.pid, rs.value, t);
1656
+ ready.innerHTML = r.html;
1657
+ ready.hidden = !r.html;
1658
+ ready.dataset.tone = r.tone;
1659
+ go.disabled = !r.ok;
1660
+ go.dataset.blocked = r.ok ? "" : "runtime";
1661
+ }
1662
+
1663
+ /** Advanced (AG-12, AG-13): model, version and the permission block. */
1664
+ function paintAdvanced() {
1665
+ const p = curPersona();
1666
+ const body = $d("#ag-adv-b");
1667
+ if (!p) {
1668
+ body.innerHTML = "";
1669
+ return;
1670
+ }
1671
+ const rt = chosenRuntime();
1672
+ const rtv = (sd.data?.runtimes ?? []).find((r) => r.id === rt);
1673
+ const name = runtimeName(sd.data?.runtimes ?? [], rt);
1674
+ const def = personaModel();
1675
+ const models = rtv?.models ?? [];
1676
+ const model = sd.model || def.model;
1677
+ const first = t("ag.new.modelDef", {
1678
+ m: `${def.model || t("pe.rtDefault")} · ${versionLabel(def.model ? def.version : "latest")}`,
1679
+ });
1680
+ const verFirst = t("ag.new.verDef", {
1681
+ v: versionLabel(
1682
+ sd.model && sd.model !== def.model ? "latest" : def.version,
1683
+ ),
1684
+ });
1685
+ body.innerHTML =
1686
+ `<p>${esc(t("ag.new.advNote"))}</p>` +
1687
+ `<div class="two"><label class="fld"><span>${esc(t("ag.new.model"))}</span><select id="ag-model"${models.length ? "" : " disabled"}>${models.length ? modelOptionsHtml(sd.data, rt, sd.model, first) : `<option value="">${esc(t("pe.nsModel", { r: name }))}</option>`}</select></label>` +
1688
+ `<label class="fld"><span>${esc(t("ag.new.ver"))}</span><select id="ag-ver"${models.length && model ? "" : " disabled"}>${versionOptionsHtml(sd.data, rt, model, sd.ver, verFirst)}</select></label></div>` +
1689
+ permBlockHtml("ag", sd.data, {
1690
+ intent: sd.intent,
1691
+ role: p.role,
1692
+ extras: sd.extras,
1693
+ runtime: rt,
1694
+ extrasOpen: sd.extrasOpen,
1695
+ disabled: !rtv?.permissions,
1696
+ });
1697
+ $d("#ag-tools").disabled = false;
1698
+ }
1699
+
1700
+ /** Pick persona `id` (or PLAIN): its own choices fill Advanced in. */
1701
+ function pickPersona(id) {
1702
+ sd.pid = id;
1703
+ sd.det = sd.det && id !== PLAIN;
1704
+ const p = curPersona();
1705
+ sd.model = "";
1706
+ sd.ver = "";
1707
+ sd.intent = p?.permission || "ask";
1708
+ sd.extras = [...(p?.allowedCommands ?? [])];
1709
+ sd.extrasOpen = sd.extras.length > 0;
1710
+ for (const b of st.root.querySelectorAll("#ag-persona [data-pid]"))
1711
+ b.setAttribute("aria-checked", String(b.dataset.pid === id));
1712
+ paintDetails();
1713
+ paintRuntime(true);
1714
+ paintAdvanced();
1715
+ if (sd.det) void loadDetail();
1716
+ }
1717
+
1718
+ async function loadDetail() {
1719
+ const p = curPersona();
1720
+ if (!p) return;
1721
+ try {
1722
+ const d = await personaDetail(p.id);
1723
+ if (curPersona()?.id !== d.id) return;
1724
+ sd.detail = d;
1725
+ paintDetails();
1726
+ } catch (err) {
1727
+ window.denShell?.errorToast?.(err);
1728
+ }
1729
+ }
1730
+
1731
+ /** The whole persona field, for the data asked. */
1732
+ function paintPersonaField() {
1733
+ const ps = sd.data?.personas ?? [];
1734
+ $d("#ag-pfbar").innerHTML = filterBarHtml(
1735
+ sd.f,
1736
+ "ag",
1737
+ ps,
1738
+ sd.data?.categories ?? [],
1739
+ );
1740
+ $d("#ag-what").hidden = !sd.what;
1741
+ paintPlist(false);
1742
+ }
1743
+
1744
+ /** Ask for the workspace's personas and runtimes; false when it failed. */
1010
1745
  async function fillForWorkspace(ws) {
1011
- let data;
1012
1746
  try {
1013
- data = await personasFor(ws);
1747
+ sd.data = normalizeData(await personasFor(ws));
1014
1748
  } catch (err) {
1015
1749
  window.denShell?.errorToast?.(err);
1016
1750
  return false;
1017
1751
  }
1018
- const form = st.root.querySelector("#ag-form");
1019
- const keep = [
1020
- form.querySelector("#ag-persona").value,
1021
- form.querySelector("#ag-runtime").value,
1022
- ];
1023
- fillStartForm(form, data, t);
1024
- // The persona and runtime picked stay, where the new list still has them.
1025
- const ps = form.querySelector("#ag-persona");
1026
- if (keep[0] && [...ps.options].some((o) => o.value === keep[0])) {
1027
- ps.value = keep[0];
1028
- ps.onchange?.();
1029
- }
1030
- const rs = form.querySelector("#ag-runtime");
1031
- if ([...rs.options].some((o) => o.value === keep[1] && !o.disabled)) {
1032
- rs.value = keep[1];
1033
- rs.onchange?.();
1752
+ const ps = sd.data?.personas ?? [];
1753
+ if (sd.pid !== PLAIN && !ps.some((p) => p.id === sd.pid)) {
1754
+ // The first persona the list shows (grouped by category).
1755
+ const cats = (sd.data?.categories ?? []).map((c) => c.id);
1756
+ const sorted = [...ps].sort(
1757
+ (a, b) =>
1758
+ (cats.indexOf(a.category) + 1 || 99) -
1759
+ (cats.indexOf(b.category) + 1 || 99),
1760
+ );
1761
+ sd.pid = sorted[0]?.id ?? PLAIN;
1034
1762
  }
1763
+ paintPersonaField();
1764
+ pickPersona(sd.pid);
1035
1765
  return true;
1036
1766
  }
1037
1767
 
1038
1768
  async function openStart() {
1039
- const dlg = st.root.querySelector("#ag-dlg");
1769
+ const dlg = $d("#ag-dlg");
1040
1770
  if (!dlg || dlg.open) return;
1041
1771
  const shown = viewWorkspaces();
1042
- const wsSel = st.root.querySelector("#ag-ws");
1772
+ const wsSel = $d("#ag-ws");
1043
1773
  wsSel.innerHTML = shown
1044
1774
  .map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
1045
1775
  .join("");
1046
- st.root.querySelector("#ag-ws-l").hidden = shown.length <= 1;
1776
+ wsSel.disabled = shown.length <= 1;
1777
+ sd.f = { q: "", cat: "", src: "" };
1778
+ sd.det = false;
1779
+ sd.what = false;
1780
+ $d("#ag-err").hidden = true;
1781
+ $d("#ag-task").value = "";
1782
+ $d("#ag-adv").open = false;
1047
1783
  if (!(await fillForWorkspace(wsSel.value || shown[0]))) return;
1048
1784
  if (dlg.open) return;
1049
1785
  if (typeof dlg.showModal === "function") dlg.showModal();
1050
1786
  else dlg.setAttribute("open", "");
1787
+ $d("#ag-q")?.focus();
1788
+ }
1789
+
1790
+ /** What Start sends (agent-routes.ts checks each field again). */
1791
+ function startBody() {
1792
+ const p = curPersona();
1793
+ const rt = chosenRuntime();
1794
+ const rtv = (sd.data?.runtimes ?? []).find((r) => r.id === rt);
1795
+ const runtime = $d("#ag-runtime").value;
1796
+ const body = { persona: p.id, ...(runtime ? { runtime } : {}) };
1797
+ const def = personaModel();
1798
+ if (rtv?.models?.length) {
1799
+ if (sd.model) body.model = sd.model;
1800
+ if (sd.ver) {
1801
+ body.version = sd.ver;
1802
+ body.model ??= def.model || undefined;
1803
+ if (!body.model) delete body.version;
1804
+ }
1805
+ }
1806
+ if (rtv?.permissions) {
1807
+ // A persona's own mode Den doesn't name (Claude Code's plan) stays its
1808
+ // own unless a mode is picked.
1809
+ if (p.permission !== undefined || sd.intent !== "ask")
1810
+ body.permission = sd.intent;
1811
+ body.allow = sd.extras;
1812
+ }
1813
+ const task = $d("#ag-task").value.trim();
1814
+ if (task) body.instruction = task;
1815
+ return body;
1816
+ }
1817
+
1818
+ function showStartError(err) {
1819
+ const el = $d("#ag-err");
1820
+ el.textContent = err?.message || String(err);
1821
+ el.hidden = false;
1051
1822
  }
1052
1823
 
1053
1824
  async function submitStart(form) {
1054
- const dlg = st.root.querySelector("#ag-dlg");
1055
- const go = st.root.querySelector("#ag-go");
1825
+ const dlg = $d("#ag-dlg");
1826
+ const go = $d("#ag-go");
1056
1827
  if (go.disabled) return;
1057
1828
  const ws = form.querySelector("#ag-ws").value || viewWorkspaces()[0];
1058
- const persona = form.querySelector("#ag-persona").value;
1059
- const runtime = form.querySelector("#ag-runtime").value;
1060
- if (!ws || !persona) return;
1829
+ if (!ws || !sd.pid) return;
1830
+ const bad = sd.extras.find((c) => commandProblem(c));
1831
+ if (sd.pid !== PLAIN && bad)
1832
+ return showStartError(new Error(t("pe.badCmd", { c: bad })));
1061
1833
  go.disabled = true;
1062
1834
  go.textContent = t("ag.starting");
1063
1835
  try {
1836
+ if (sd.pid === PLAIN) {
1837
+ const r = await api(
1838
+ "POST",
1839
+ `/api/workspaces/${encodeURIComponent(ws)}/sessions`,
1840
+ );
1841
+ window.denShell?.toast?.(
1842
+ t("ag.new.started", { p: t("ag.session"), w: ws }),
1843
+ );
1844
+ dlg.close?.();
1845
+ await refresh();
1846
+ if (r?.session?.id) await openPane(r.session.id);
1847
+ return;
1848
+ }
1064
1849
  const r = await api(
1065
1850
  "POST",
1066
1851
  `/api/workspaces/${encodeURIComponent(ws)}/agents`,
1067
- {
1068
- persona,
1069
- ...(runtime ? { runtime } : {}),
1070
- },
1852
+ startBody(),
1071
1853
  );
1072
- window.denShell?.toast?.(t("ag.started", { id: r?.agent?.id ?? persona }));
1854
+ window.denShell?.toast?.(t("ag.started", { id: r?.agent?.id ?? sd.pid }));
1855
+ for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
1073
1856
  dlg.close?.();
1074
1857
  await refresh();
1075
1858
  if (r?.agent?.sessionId) await openPane(r.agent.sessionId);
1076
1859
  } catch (err) {
1077
- window.denShell?.errorToast?.(err);
1860
+ showStartError(err);
1078
1861
  } finally {
1079
1862
  go.disabled = false;
1080
1863
  go.textContent = t("ag.go");
1864
+ if (dlg.open) paintRuntime(false);
1865
+ }
1866
+ }
1867
+
1868
+ /** New persona, Edit or Duplicate from the dialog; back to it after. */
1869
+ async function editFromStart(kind) {
1870
+ const p = curPersona();
1871
+ let src = null;
1872
+ if (kind !== "new" && p) {
1873
+ try {
1874
+ src = await personaDetail(p.id);
1875
+ } catch (err) {
1876
+ return void window.denShell?.errorToast?.(err);
1877
+ }
1081
1878
  }
1879
+ openPersonaEditor({
1880
+ data: sd.data,
1881
+ src,
1882
+ dup: kind === "dup",
1883
+ onSaved: async (saved) => {
1884
+ personasAsked.clear();
1885
+ if (saved) sd.pid = saved.id;
1886
+ await fillForWorkspace($d("#ag-ws").value);
1887
+ },
1888
+ });
1889
+ }
1890
+
1891
+ /** Wire the dialog once (mountAgentsPage). */
1892
+ function wireStartDialog(root) {
1893
+ const form = root.querySelector("#ag-form");
1894
+ form.addEventListener("click", (e) => {
1895
+ const pr = e.target.closest("#ag-persona [data-pid]");
1896
+ if (pr) {
1897
+ e.preventDefault();
1898
+ pickPersona(pr.dataset.pid);
1899
+ pr.focus();
1900
+ return;
1901
+ }
1902
+ const chip = e.target.closest("[data-pf]");
1903
+ if (chip) {
1904
+ e.preventDefault();
1905
+ sd.f[chip.dataset.pf] = chip.dataset.v;
1906
+ for (const c of form.querySelectorAll(`[data-pf="${chip.dataset.pf}"]`))
1907
+ c.setAttribute("aria-pressed", String(c.dataset.v === chip.dataset.v));
1908
+ return paintPlist(false);
1909
+ }
1910
+ const pm = e.target.closest("[data-pm]");
1911
+ if (pm) {
1912
+ e.preventDefault();
1913
+ if (pm.disabled) return;
1914
+ sd.intent = pm.dataset.pm;
1915
+ return syncPermBlock(form, "ag", sd.data, {
1916
+ intent: sd.intent,
1917
+ role: curPersona()?.role,
1918
+ extras: sd.extras,
1919
+ runtime: chosenRuntime(),
1920
+ });
1921
+ }
1922
+ const x = e.target.closest("[data-x]")?.dataset.x;
1923
+ if (!x) return;
1924
+ e.preventDefault();
1925
+ if (x === "what") {
1926
+ sd.what = !sd.what;
1927
+ $d("#ag-what").hidden = !sd.what;
1928
+ e.target
1929
+ .closest("[data-x]")
1930
+ .setAttribute("aria-expanded", String(sd.what));
1931
+ } else if (x === "det") {
1932
+ sd.det = !sd.det;
1933
+ paintDetails();
1934
+ if (sd.det) void loadDetail();
1935
+ } else if (x === "clear") {
1936
+ sd.f = { q: "", cat: "", src: "" };
1937
+ paintPersonaField();
1938
+ } else if (x === "raw") window.denShell?.toast?.(t("pe.rawLater"));
1939
+ else if (x === "newp") void editFromStart("new");
1940
+ else if (x === "dup") void editFromStart("dup");
1941
+ else if (x === "edit") void editFromStart("edit");
1942
+ });
1943
+ form.addEventListener("input", (e) => {
1944
+ if (e.target.id === "ag-q") {
1945
+ sd.f.q = e.target.value;
1946
+ paintPlist(false);
1947
+ } else if (e.target.id === "ag-tools") {
1948
+ sd.extras = splitCommands(e.target.value);
1949
+ syncPermBlock(form, "ag", sd.data, {
1950
+ intent: sd.intent,
1951
+ role: curPersona()?.role,
1952
+ extras: sd.extras,
1953
+ runtime: chosenRuntime(),
1954
+ });
1955
+ }
1956
+ });
1957
+ form.addEventListener(
1958
+ "toggle",
1959
+ (e) => {
1960
+ if (e.target.id === "ag-xtra") sd.extrasOpen = e.target.open;
1961
+ },
1962
+ true,
1963
+ );
1964
+ form.addEventListener("change", (e) => {
1965
+ const id = e.target.id;
1966
+ if (id === "ag-ws") void fillForWorkspace(e.target.value);
1967
+ else if (id === "ag-runtime") {
1968
+ sd.model = "";
1969
+ sd.ver = "";
1970
+ $d("#ag-r-fb").hidden = true;
1971
+ paintRuntime(false);
1972
+ paintAdvanced();
1973
+ } else if (id === "ag-model") {
1974
+ sd.model = e.target.value;
1975
+ sd.ver = "";
1976
+ paintAdvanced();
1977
+ } else if (id === "ag-ver") sd.ver = e.target.value;
1978
+ });
1979
+ // ↑ ↓ move through the persona list (a radio group).
1980
+ root.querySelector("#ag-persona").addEventListener("keydown", (e) => {
1981
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
1982
+ const rows = [...e.currentTarget.querySelectorAll("[data-pid]")];
1983
+ const i = rows.indexOf(document.activeElement);
1984
+ const next = rows[i + (e.key === "ArrowDown" ? 1 : -1)];
1985
+ if (!next) return;
1986
+ e.preventDefault();
1987
+ pickPersona(next.dataset.pid);
1988
+ next.focus();
1989
+ });
1082
1990
  }
1083
1991
 
1084
1992
  /**
@@ -1125,14 +2033,33 @@ function translateStatic(root) {
1125
2033
  set("#ag-h", "n.agents");
1126
2034
  set("#ag-start", "ag.start");
1127
2035
  set("#ag-clear", "ag.clear");
1128
- set("#ag-dlg-h", "ag.start");
2036
+ set("#ag-dlg-h", "ag.new.title");
1129
2037
  set("#ag-ws-t", "ag.ws");
1130
2038
  set("#ag-p-t", "ag.persona");
1131
2039
  set("#ag-r-t", "ag.runtime");
2040
+ set("#ag-what-b", "ag.new.what");
2041
+ set("#ag-newp", "ag.new.newP");
2042
+ set("#ag-what-p", "pe.what");
2043
+ set("#ag-task-t", "ag.new.task");
2044
+ set("#ag-adv-s", "ag.new.adv");
2045
+ set("#ag-note", "ag.new.note");
2046
+ const task = root.querySelector("#ag-task");
2047
+ if (task) task.placeholder = t("ag.new.taskPh");
2048
+ root
2049
+ .querySelector("#ag-persona")
2050
+ ?.setAttribute("aria-label", t("ag.persona"));
1132
2051
  set("#ag-cancel", "ag.cancel");
1133
2052
  set("#ag-go", "ag.go");
1134
- const h = root.querySelector("#ag-dlg-h");
1135
- if (h) h.textContent = h.textContent.replace(/^\+\s*/, "");
2053
+ for (const b of root.querySelectorAll("[data-layouts] button")) {
2054
+ b.textContent = t(`ag.l.${b.dataset.layout}`);
2055
+ b.title = t("ag.l.tip", {
2056
+ name: b.textContent,
2057
+ key: `⌃⇧${LAYOUTS.indexOf(b.dataset.layout) + 1}`,
2058
+ });
2059
+ }
2060
+ root
2061
+ .querySelector("[data-layouts]")
2062
+ ?.setAttribute("aria-label", t("ag.layout"));
1136
2063
  }
1137
2064
 
1138
2065
  /** Fill the page drawn by agents.html and wire it. */
@@ -1143,6 +2070,7 @@ export async function mountAgentsPage(root) {
1143
2070
  if (window.denShell?.apiOff?.()) return;
1144
2071
  st.open = [];
1145
2072
  const wanted = openFromUrl(location.search);
2073
+ const linked = layoutFromUrl(location.search);
1146
2074
  const startAsked = new URLSearchParams(location.search).get("start") === "1";
1147
2075
 
1148
2076
  root.querySelector("#agl").addEventListener("click", (e) => {
@@ -1166,14 +2094,29 @@ export async function mountAgentsPage(root) {
1166
2094
  const b = e.target.closest(".agr");
1167
2095
  if (!b) return;
1168
2096
  if (b.getAttribute("aria-disabled") === "true") return;
1169
- const id = b.dataset.id;
1170
- if (st.panes.has(id)) st.panes.get(id).term?.focus();
1171
- else void openPane(id);
2097
+ void pick(b.dataset.id, e.shiftKey || e.metaKey || e.ctrlKey);
2098
+ });
2099
+ root.querySelector("[data-layouts]")?.addEventListener("click", (e) => {
2100
+ const b = e.target.closest("button[data-layout]");
2101
+ if (b) setLayout(b.dataset.layout);
2102
+ });
2103
+ root.querySelector("#agr").addEventListener("dblclick", (e) => {
2104
+ const tab = e.target.closest('[role="tab"]');
2105
+ if (tab) renameTab(tab);
1172
2106
  });
1173
2107
  root.querySelector("#agr").addEventListener("click", (e) => {
2108
+ const tab = e.target.closest('[role="tab"]');
2109
+ if (tab) {
2110
+ if (st.cur === tab.dataset.id) return;
2111
+ st.cur = tab.dataset.id;
2112
+ paintLayout();
2113
+ paintList();
2114
+ tab.focus();
2115
+ return;
2116
+ }
1174
2117
  const b = e.target.closest("button[data-act]");
1175
2118
  if (!b) return;
1176
- const id = b.closest(".pane")?.dataset.id;
2119
+ const id = b.closest(".pane")?.dataset.id ?? b.dataset.id;
1177
2120
  const p = id && st.panes.get(id);
1178
2121
  if (!p) return;
1179
2122
  if (b.dataset.act === "close") closePane(id);
@@ -1189,9 +2132,7 @@ export async function mountAgentsPage(root) {
1189
2132
  root.querySelector("#ag-clear")?.addEventListener("click", (e) => {
1190
2133
  void clearStopped(e.currentTarget);
1191
2134
  });
1192
- root.querySelector("#ag-ws").addEventListener("change", (e) => {
1193
- void fillForWorkspace(e.currentTarget.value);
1194
- });
2135
+ wireStartDialog(root);
1195
2136
  root.querySelector("#ag-cancel").addEventListener("click", () => {
1196
2137
  root.querySelector("#ag-dlg").close?.();
1197
2138
  });
@@ -1203,7 +2144,7 @@ export async function mountAgentsPage(root) {
1203
2144
  // cancels (the dialog's own cancel). A button keeps its own Enter.
1204
2145
  root.querySelector("#ag-form").addEventListener("keydown", (e) => {
1205
2146
  if (e.key !== "Enter" || e.isComposing) return;
1206
- if (e.target.closest("button, textarea")) return;
2147
+ if (e.target.closest("button, textarea, summary")) return;
1207
2148
  e.preventDefault();
1208
2149
  if (!st.root.querySelector("#ag-go").disabled)
1209
2150
  e.currentTarget.requestSubmit();
@@ -1217,6 +2158,33 @@ export async function mountAgentsPage(root) {
1217
2158
  attributeFilter: ["data-mode", "data-theme", "class"],
1218
2159
  });
1219
2160
  if (!st.docWired) wireDocument();
2161
+ // ⌃⇧1 / ⌃⇧2 / ⌃⇧3 on every OS: the layouts, even from a terminal (whose
2162
+ // key handler never passes them to the agent). ⌘1-9 and Ctrl+1-9 are
2163
+ // the browser's (its tabs): the page leaves them alone.
2164
+ document.addEventListener(
2165
+ "keydown",
2166
+ (e) => {
2167
+ const i = layoutKeyIndex(e);
2168
+ if (i < 0 || st.root.querySelector("#ag-dlg")?.open) return;
2169
+ e.preventDefault();
2170
+ e.stopPropagation();
2171
+ setLayout(LAYOUTS[i]);
2172
+ },
2173
+ true,
2174
+ );
2175
+ // ⌘K: "Layout: Focus", "Layout: Tabs", "Layout: Tile".
2176
+ for (const l of LAYOUTS)
2177
+ window.denShell?.registerCommand?.({
2178
+ id: `agents-layout:${l}`,
2179
+ label: () => t("ag.pal.layout", { name: t(`ag.l.${l}`) }),
2180
+ keywords: ["layout", "agents", l, t(`ag.l.${l}`)],
2181
+ run: () => setLayout(l),
2182
+ });
2183
+ // The phone width comes and goes: Tabs there, the person's layout here.
2184
+ window.matchMedia?.(PHONE).addEventListener?.("change", () => {
2185
+ paintLayout();
2186
+ paintList();
2187
+ });
1220
2188
  window.addEventListener("pagehide", () => {
1221
2189
  for (const p of st.panes.values()) {
1222
2190
  p.closed = true;
@@ -1228,7 +2196,9 @@ export async function mountAgentsPage(root) {
1228
2196
  }
1229
2197
  });
1230
2198
 
1231
- await loadScope();
2199
+ const [, saved] = await Promise.all([loadScope(), savedLayout()]);
2200
+ // A link's &layout= wins for this page; else the person's.
2201
+ st.layout = linked ?? saved;
1232
2202
  await refresh();
1233
2203
  paintLayout();
1234
2204
  // An id the list no longer has (removed, another distribution's) is