@monster-orange/den 0.4.11 → 0.4.12

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.
@@ -41,6 +41,30 @@
41
41
  * agent in view. After a restart, one line says how many were interrupted,
42
42
  * with Resume all and Review.
43
43
  *
44
+ * Layouts (AG-4b): Focus (the default: one pane; a click replaces it,
45
+ * Shift- or ⌘-click adds one alongside), Tabs (one pane under a strip of
46
+ * named tabs) or Tile (up to four in a grid: 1 full, 2 side by side, 3-4
47
+ * as 2x2). The page header's switch, ⌃⇧1 / ⌃⇧2 / ⌃⇧3 (Ctrl+Shift+digit
48
+ * on every OS: ⌘1-9 and Ctrl+1-9 are the browser's tabs) and ⌘K "Layout:
49
+ * …" pick one; it is the person's (POST /api/settings agents_layout) and the URL's (&layout=; a
50
+ * link's wins for that page and saves nothing). At phone width Focus and
51
+ * Tile are Tabs. Every pane open stays attached; a pane out of sight never
52
+ * resizes its PTY. Double-click a tab to rename the agent: the name is its
53
+ * record's (POST …/agents/<id>/name), shown in the list, the pane, ⌘K.
54
+ *
55
+ * The terminal (AG-4b): Shift+Enter sends the runtime's soft newline (its
56
+ * catalog softNewline, from /api/sessions: ESC CR, Codex's Ctrl+J; a
57
+ * plain session's is Enter) and nothing else. The layout keys never reach
58
+ * the agent. Links: plain URLs (addon-web-links) and OSC 8 links open on
59
+ * ⌘-click (Ctrl-click elsewhere) in a new tab with noopener, http and
60
+ * https only; a plain click selects as ever. A file path is a link when
61
+ * the server says it exists inside the agent's workspace (GET …/paths,
62
+ * stat only): ⌘-click opens it in Settings' editor_open (vscode, cursor,
63
+ * zed, by URL scheme, at the line), or, with none (the default), copies
64
+ * its absolute path. The session's name (its runtime's /rename, or Den's
65
+ * rename, whichever is later) follows the runtime in the pane header and
66
+ * the persona in its tab, when it says more than the agent's own name.
67
+ *
44
68
  * Session rotation (den#4): an agent whose session rotated (after
45
69
  * agents.rotate.after_compactions compactions, or its pull request merged)
46
70
  * and whose handoff.md passes the check reads "Ready to relaunch (3
@@ -51,6 +75,14 @@
51
75
  * as a tag.
52
76
  */
53
77
  import { fillStartForm } from "/agent-start.js";
78
+ import {
79
+ editorUrl,
80
+ findPaths,
81
+ isWebUrl,
82
+ layoutKeyIndex,
83
+ linkModifier,
84
+ softNewlineKey,
85
+ } from "/term-input.js";
54
86
 
55
87
  const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
56
88
  const esc = (s) =>
@@ -69,6 +101,13 @@ export const UNDO_MS = 10_000;
69
101
 
70
102
  /** The most panes at once (the prototype's 2x2). */
71
103
  export const MAX_PANES = 4;
104
+
105
+ /** The layouts (AG-4b), in the switch's order (⌃⇧1, ⌃⇧2, ⌃⇧3). */
106
+ export const LAYOUTS = ["focus", "tabs", "tile"];
107
+ /** At or below this width Focus and Tile are Tabs (the shell's phone width). */
108
+ const PHONE = "(max-width: 720px)";
109
+ /** The longest name a tab takes (records.ts MAX_DISPLAY_NAME). */
110
+ const MAX_NAME = 40;
72
111
  const REFRESH_MS = 5_000;
73
112
 
74
113
  const st = {
@@ -83,8 +122,37 @@ const st = {
83
122
  xterm: null,
84
123
  csrf: null,
85
124
  docWired: false,
125
+ // AG-4b: the person's layout, the pane in front, and in Focus the panes
126
+ // Shift-clicked in alongside it.
127
+ layout: "focus",
128
+ cur: null,
129
+ side: new Set(),
130
+ // AG-4b: where a path link opens (Settings editor_open).
131
+ editorOpen: "none",
86
132
  };
87
133
 
134
+ /** ?layout=, when it names one; else null. */
135
+ export function layoutFromUrl(search) {
136
+ const l = new URLSearchParams(search).get("layout");
137
+ return LAYOUTS.includes(l) ? l : null;
138
+ }
139
+
140
+ const phone = () => !!window.matchMedia?.(PHONE).matches;
141
+
142
+ /** The layout drawn: the person's, except at phone width (Tabs). */
143
+ function shownLayout() {
144
+ return phone() ? "tabs" : st.layout;
145
+ }
146
+
147
+ /** The panes in sight, in the URL's order. */
148
+ function visibleIds() {
149
+ const l = shownLayout();
150
+ if (l === "tile") return [...st.open];
151
+ if (!st.open.includes(st.cur)) st.cur = st.open.at(-1) ?? null;
152
+ if (l === "tabs") return st.cur ? [st.cur] : [];
153
+ return st.open.filter((id) => id === st.cur || st.side.has(id));
154
+ }
155
+
88
156
  // ── the URL ─────────────────────────────────────────────────────────────
89
157
 
90
158
  /** The ids ?open= names (at most MAX_PANES, in order, no repeats). */
@@ -101,6 +169,7 @@ function writeUrl() {
101
169
  const u = new URL(location.href);
102
170
  if (st.open.length) u.searchParams.set("open", st.open.join(","));
103
171
  else u.searchParams.delete("open");
172
+ u.searchParams.set("layout", st.layout);
104
173
  u.searchParams.delete("start");
105
174
  const s = u.searchParams.toString().replace(/%2C/gi, ",");
106
175
  history.replaceState(history.state, "", u.pathname + (s ? `?${s}` : ""));
@@ -147,7 +216,34 @@ function stateText(state) {
147
216
  }
148
217
 
149
218
  function displayName(r) {
150
- return r.kind === "agent" ? (r.persona ?? r.label) : r.label;
219
+ if (r.kind !== "agent") return r.label;
220
+ return r.displayName || (r.persona ?? r.label);
221
+ }
222
+
223
+ /**
224
+ * A row's name: the name a person gave it (AG-4b), then its persona and
225
+ * id; else its persona, then its id (when they differ).
226
+ */
227
+ function nameHtml(r) {
228
+ const extra = sessionExtra(r);
229
+ if (r.displayName)
230
+ return `${esc(r.displayName)} <span class="id">${esc([r.persona, r.label, extra].filter(Boolean).join(" · "))}</span>`;
231
+ const id = [r.persona && r.persona !== r.label ? r.label : "", extra]
232
+ .filter(Boolean)
233
+ .join(" · ");
234
+ return `${esc(displayName(r))}${id ? ` <span class="id">${esc(id)}</span>` : ""}`;
235
+ }
236
+
237
+ /**
238
+ * The session's name (AG-4b: the runtime's /rename or Den's, the later),
239
+ * where it says more than the agent's own name: not empty, not the
240
+ * persona, not the name already shown. Else "".
241
+ */
242
+ function sessionExtra(r) {
243
+ const n = r?.kind === "agent" ? (r.sessionName ?? "").trim() : "";
244
+ if (!n || n === r.persona || n === paneName(r) || n === displayName(r))
245
+ return "";
246
+ return n;
151
247
  }
152
248
 
153
249
  // A pane's title and its Close: the agent's display name (AG-10.8), else
@@ -211,7 +307,7 @@ function rotationOf(r) {
211
307
  function stoppedHtml(r) {
212
308
  const open = st.open.includes(r.id);
213
309
  const name = displayName(r);
214
- const nm = `${esc(name)}${r.persona && r.persona !== r.label ? ` <span class="id">${esc(r.label)}</span>` : ""}`;
310
+ const nm = nameHtml(r);
215
311
  const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
216
312
  const who = esc(r.agent);
217
313
  const rot = rotationOf(r);
@@ -264,7 +360,7 @@ function rowHtml(r) {
264
360
  const open = st.open.includes(r.id);
265
361
  const nm =
266
362
  r.kind === "agent"
267
- ? `${esc(displayName(r))}${r.persona && r.persona !== r.label ? ` <span class="id">${esc(r.label)}</span>` : ""}`
363
+ ? nameHtml(r)
268
364
  : `${esc(r.label)} <span class="sbdg">${esc(t("ag.session"))}</span>`;
269
365
  const right = r.waiting
270
366
  ? `<span class="wbdg">${esc(t("ag.waiting"))}</span>`
@@ -289,7 +385,7 @@ function rowHtml(r) {
289
385
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
290
386
  ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
291
387
  ` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${displayName(r)}` }))}">` +
292
- `<span class="den-dot ${stateTone(r)}" aria-hidden="true"></span>` +
388
+ `<span class="den-dot ${r.waiting ? "warn" : stateTone(r)}" aria-hidden="true"></span>` +
293
389
  `<span class="nm">${nm}</span>${right}` +
294
390
  (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
295
391
  tags +
@@ -360,7 +456,10 @@ function paintList() {
360
456
  )
361
457
  .join("")
362
458
  : `<div class="none">${esc(t("ag.none.scope", { scope: st.scopeLabel }))}</div>`;
363
- el.innerHTML = notes.join("") + body;
459
+ el.innerHTML =
460
+ notes.join("") +
461
+ body +
462
+ `<div class="aghint" id="agl-hint">${esc(t(`ag.hint.${shownLayout()}`))} ${esc(t("ag.hint.keys"))}</div>`;
364
463
  const clear = st.root.querySelector("#ag-clear");
365
464
  if (clear) clear.hidden = !visibleRows().some(isStopped);
366
465
  // No such workspace: nothing to start an agent in.
@@ -388,6 +487,21 @@ function paintSub() {
388
487
  if (url) url.textContent = location.pathname + location.search;
389
488
  }
390
489
 
490
+ /**
491
+ * The person's layout (GET /api/settings agents_layout), else Focus; and
492
+ * where a path link opens (editor_open), else none.
493
+ */
494
+ async function savedLayout() {
495
+ try {
496
+ const r = await fetch("/api/settings", { headers: { accept: "json" } });
497
+ const b = r.ok ? await r.json() : null;
498
+ if (typeof b?.editor_open === "string") st.editorOpen = b.editor_open;
499
+ return LAYOUTS.includes(b?.agents_layout) ? b.agents_layout : "focus";
500
+ } catch {
501
+ return "focus";
502
+ }
503
+ }
504
+
391
505
  async function loadScope() {
392
506
  try {
393
507
  const r = await fetch("/api/scope");
@@ -425,6 +539,8 @@ async function refresh() {
425
539
  paintList();
426
540
  paintSub();
427
541
  for (const p of st.panes.values()) paintBar(p);
542
+ const strip = st.root.querySelector("#agr > .agtabs");
543
+ if (strip) paintTabs(strip);
428
544
  if (!st.root.isConnected) return;
429
545
  st.timer = setTimeout(() => {
430
546
  if (!document.hidden) void refresh();
@@ -440,7 +556,12 @@ async function loadXterm() {
440
556
  st.xterm ??= Promise.all([
441
557
  import("/vendor/xterm.mjs"),
442
558
  import("/vendor/addon-fit.mjs"),
443
- ]).then(([x, f]) => ({ Terminal: x.Terminal, FitAddon: f.FitAddon }));
559
+ import("/vendor/addon-web-links.mjs"),
560
+ ]).then(([x, f, w]) => ({
561
+ Terminal: x.Terminal,
562
+ FitAddon: f.FitAddon,
563
+ WebLinksAddon: w.WebLinksAddon,
564
+ }));
444
565
  return st.xterm;
445
566
  }
446
567
 
@@ -479,30 +600,233 @@ function termTheme() {
479
600
  return theme;
480
601
  }
481
602
 
603
+ /** A tab's words: the agent's name, then its persona and workspace. */
604
+ function tabWords(id) {
605
+ const r = rowOf(id);
606
+ const p = st.panes.get(id);
607
+ if (!r) return { name: p?.label || id, meta: p?.workspace || "" };
608
+ if (r.kind !== "agent")
609
+ return {
610
+ name: r.label,
611
+ meta: [t("ag.session"), r.workspace].join(" · "),
612
+ };
613
+ return {
614
+ name: paneName(r),
615
+ meta: [r.persona, r.workspace, sessionExtra(r)].filter(Boolean).join(" · "),
616
+ };
617
+ }
618
+
619
+ /** The tab strip (AG-4b Tabs): one tab per open pane, its × beside it. */
620
+ function paintTabs(strip) {
621
+ // A tab being renamed keeps its input.
622
+ if (strip.querySelector("input")) return;
623
+ const have = [...strip.querySelectorAll(":scope > .agtab-w")].map(
624
+ (w) => w.dataset.id,
625
+ );
626
+ // The same tabs: changed in place, so a double-click's target stays.
627
+ if (have.join("\n") === st.open.join("\n")) {
628
+ for (const w of strip.querySelectorAll(":scope > .agtab-w")) {
629
+ const id = w.dataset.id;
630
+ const r = rowOf(id);
631
+ const words = tabWords(id);
632
+ const on = id === st.cur;
633
+ const tab = w.querySelector('[role="tab"]');
634
+ tab.setAttribute("aria-selected", String(on));
635
+ tab.tabIndex = on ? 0 : -1;
636
+ tab.querySelector(".den-dot").className =
637
+ `den-dot ${r?.waiting ? "warn" : r ? stateTone(r) : "pending"}`;
638
+ tab.querySelector(".tn").textContent = words.name;
639
+ tab.querySelector(".tm").textContent = words.meta;
640
+ const sr = tab.querySelector(".den-sr");
641
+ if (sr) sr.hidden = !r?.waiting;
642
+ w.querySelector(".agtab-x").setAttribute(
643
+ "aria-label",
644
+ t("ag.detachName", { id: words.name }),
645
+ );
646
+ }
647
+ return;
648
+ }
649
+ strip.innerHTML = st.open
650
+ .map((id) => {
651
+ const r = rowOf(id);
652
+ const w = tabWords(id);
653
+ const on = id === st.cur;
654
+ const tone = r?.waiting ? "warn" : r ? stateTone(r) : "pending";
655
+ const rename =
656
+ r?.kind === "agent" ? ` title="${esc(t("ag.renameTip"))}"` : "";
657
+ return (
658
+ `<span class="agtab-w" data-id="${esc(id)}">` +
659
+ `<button type="button" role="tab" class="agtab" data-id="${esc(id)}" aria-selected="${on}" tabindex="${on ? 0 : -1}"${rename}>` +
660
+ `<span class="den-dot ${tone}" aria-hidden="true"></span>` +
661
+ `<span class="den-sr"${r?.waiting ? "" : " hidden"}>${esc(t("ag.waiting"))}: </span>` +
662
+ `<span class="tn">${esc(w.name)}</span><span class="tm mono">${esc(w.meta)}</span></button>` +
663
+ `<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>` +
664
+ `</span>`
665
+ );
666
+ })
667
+ .join("");
668
+ }
669
+
482
670
  function paintLayout() {
483
671
  const box = st.root.querySelector("#agr");
484
672
  const n = st.open.length;
673
+ paintSwitch();
485
674
  if (n === 0) {
486
675
  box.className = "agempty";
676
+ delete box.dataset.layout;
487
677
  box.innerHTML = `<div>${esc(t("ag.empty"))}</div>`;
488
678
  return;
489
679
  }
490
- box.className = `agp n${n}`;
491
- const empty = box.querySelector(":scope > div:not(.pane)");
680
+ const layout = shownLayout();
681
+ const shown = visibleIds();
682
+ box.className = `agp n${Math.max(1, shown.length)}${layout === "tabs" ? " tabs" : ""}`;
683
+ box.dataset.layout = layout;
684
+ const empty = box.querySelector(":scope > div:not(.pane):not(.agtabs)");
492
685
  if (empty) empty.remove();
493
- // Panes in the URL's order.
686
+ let strip = box.querySelector(":scope > .agtabs");
687
+ if (layout === "tabs") {
688
+ if (!strip) {
689
+ strip = document.createElement("div");
690
+ strip.className = "agtabs";
691
+ strip.setAttribute("role", "tablist");
692
+ strip.setAttribute("aria-label", t("ag.tabs"));
693
+ }
694
+ box.prepend(strip);
695
+ paintTabs(strip);
696
+ // The tab in front is in sight in the strip (it scrolls sideways;
697
+ // the page itself never scrolls for it).
698
+ const on = strip.querySelector('[role="tab"][aria-selected="true"]');
699
+ const w = on?.parentElement;
700
+ if (w && strip.clientWidth > 0) {
701
+ const left = w.offsetLeft - strip.offsetLeft;
702
+ if (left < strip.scrollLeft) strip.scrollLeft = left;
703
+ else if (left + w.offsetWidth > strip.scrollLeft + strip.clientWidth)
704
+ strip.scrollLeft = left + w.offsetWidth - strip.clientWidth;
705
+ }
706
+ } else strip?.remove();
707
+ // Panes in the URL's order; those out of sight stay attached, hidden.
494
708
  for (const id of st.open) {
495
709
  const p = st.panes.get(id);
496
- if (p) box.appendChild(p.el);
710
+ if (!p) continue;
711
+ p.el.hidden = !shown.includes(id);
712
+ p.el.classList.toggle("in-tabs", layout === "tabs");
713
+ const x = p.el.querySelector('.bar2 [data-act="close"]');
714
+ if (x) x.hidden = layout === "tabs";
715
+ box.appendChild(p.el);
716
+ }
717
+ // Each pane in sight fits its new box (a hidden one is never measured).
718
+ requestAnimationFrame(() => {
719
+ for (const id of shown) {
720
+ const p = st.panes.get(id);
721
+ if (p) refit(p);
722
+ }
723
+ });
724
+ }
725
+
726
+ /** The header's switch: the person's layout pressed (AG-4b). */
727
+ function paintSwitch() {
728
+ for (const b of st.root.querySelectorAll("[data-layouts] button"))
729
+ b.setAttribute("aria-pressed", String(b.dataset.layout === st.layout));
730
+ }
731
+
732
+ /**
733
+ * Switch the layout (the header's switch, ⌘1-3): drawn at once, the URL
734
+ * follows, and it is saved for the person. Focus shows the pane in front.
735
+ */
736
+ function setLayout(layout) {
737
+ if (!LAYOUTS.includes(layout)) return;
738
+ const was = st.layout;
739
+ st.layout = layout;
740
+ st.side.clear();
741
+ paintLayout();
742
+ paintList();
743
+ writeUrl();
744
+ if (layout === was) return;
745
+ api("POST", "/api/settings", { agents_layout: layout }).catch((err) =>
746
+ window.denShell?.errorToast?.(err),
747
+ );
748
+ }
749
+
750
+ /**
751
+ * Open (or bring forward) the session of a row the person clicked, as the
752
+ * layout says: Focus replaces the pane (`add`: Shift- or ⌘-click, one
753
+ * alongside), Tabs opens a tab, Tile adds a tile.
754
+ */
755
+ async function pick(id, add) {
756
+ const layout = shownLayout();
757
+ if (layout === "focus" && !add) {
758
+ st.side.clear();
759
+ for (const other of [...st.open])
760
+ if (other !== id) closePane(other, { quiet: true });
761
+ } else if (layout === "focus" && add && st.cur && st.cur !== id)
762
+ st.side.add(id);
763
+ if (!(layout === "focus" && add)) st.cur = id;
764
+ if (st.panes.has(id)) {
765
+ paintLayout();
766
+ paintList();
767
+ st.panes.get(id).term?.focus();
768
+ return;
497
769
  }
770
+ await openPane(id, { front: !(layout === "focus" && add) });
771
+ }
772
+
773
+ /** Double-click a tab: its name becomes an input (Enter saves, Esc doesn't). */
774
+ function renameTab(tab) {
775
+ const id = tab.dataset.id;
776
+ const r = rowOf(id);
777
+ if (r?.kind !== "agent" || tab.querySelector("input")) return;
778
+ const input = document.createElement("input");
779
+ input.type = "text";
780
+ input.className = "agtab-in";
781
+ input.maxLength = MAX_NAME;
782
+ input.value = r.displayName || r.agent;
783
+ input.setAttribute("aria-label", t("ag.renameName", { id: r.agent }));
784
+ // Beside the tab, not in it (a button holds no input): the tab hides.
785
+ tab.hidden = true;
786
+ tab.before(input);
787
+ input.focus();
788
+ input.select();
789
+ let done = false;
790
+ const finish = async (save) => {
791
+ if (done) return;
792
+ done = true;
793
+ const name = input.value.trim();
794
+ input.remove();
795
+ tab.hidden = false;
796
+ if (save && name !== (r.displayName || r.agent)) {
797
+ try {
798
+ await api("POST", agentPath(r, "name"), {
799
+ name: name && name !== r.agent ? name : null,
800
+ });
801
+ } catch (err) {
802
+ window.denShell?.errorToast?.(err);
803
+ }
804
+ await refresh();
805
+ }
806
+ paintLayout();
807
+ };
808
+ input.addEventListener("keydown", (e) => {
809
+ e.stopPropagation();
810
+ if (e.key === "Enter") {
811
+ e.preventDefault();
812
+ void finish(true);
813
+ } else if (e.key === "Escape") {
814
+ e.preventDefault();
815
+ void finish(false);
816
+ }
817
+ });
818
+ input.addEventListener("blur", () => void finish(true));
819
+ // A click in the input is the input's, not the tab's.
820
+ input.addEventListener("click", (e) => e.stopPropagation());
498
821
  }
499
822
 
500
823
  function paintBar(p) {
501
824
  const r = rowOf(p.id);
502
825
  const bar = p.el.querySelector(".bar2");
503
826
  const name = r ? paneName(r) : p.label || p.id;
827
+ // AG-4b: the session's name last, when it says more.
504
828
  const where = r
505
- ? [r.workspace, r.runtime].filter(Boolean).join(" · ")
829
+ ? [r.workspace, r.runtime, sessionExtra(r)].filter(Boolean).join(" · ")
506
830
  : p.workspace || "";
507
831
  const tone = r ? stateTone(r) : p.exited ? "off" : "pending";
508
832
  const stop =
@@ -524,6 +848,9 @@ function paintBar(p) {
524
848
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
525
849
  : "") +
526
850
  `<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>`;
851
+ // Tabs: the tab's × closes it; the pane's bar has none.
852
+ const x = bar.querySelector('[data-act="close"]');
853
+ if (x) x.hidden = shownLayout() === "tabs";
527
854
  }
528
855
 
529
856
  function send(p, obj) {
@@ -695,11 +1022,111 @@ async function connect(p) {
695
1022
  };
696
1023
  }
697
1024
 
698
- async function openPane(id) {
1025
+ // ── links and keys in a pane (AG-4b) ───────────────────────────────────
1026
+
1027
+ /** What Shift+Enter sends to a pane's session (a plain session: Enter). */
1028
+ function softNewlineOf(id) {
1029
+ const seq = rowOf(id)?.softNewline;
1030
+ return typeof seq === "string" && seq ? seq : "\r";
1031
+ }
1032
+
1033
+ /**
1034
+ * A URL or an OSC 8 link clicked: with ⌘ (Ctrl elsewhere), an http or
1035
+ * https one opens in a new tab, noopener. A plain click opens nothing
1036
+ * (it selects); every other scheme (javascript:, data:, file:) nothing.
1037
+ */
1038
+ function openWeb(e, uri) {
1039
+ if (!linkModifier(e) || !isWebUrl(uri)) return;
1040
+ e?.preventDefault?.();
1041
+ window.open(uri, "_blank", "noopener");
1042
+ }
1043
+
1044
+ /** The most paths asked about at once (agent-paths.ts MAX_PATHS). */
1045
+ const MAX_ASK = 64;
1046
+
1047
+ /**
1048
+ * Which of `paths` are links for agent row `r`: absolute path or null,
1049
+ * each asked once per pane (GET …/agents/<id>/paths: the server resolves
1050
+ * them against its cwd, inside its workspace; stat only).
1051
+ */
1052
+ async function pathLinks(p, r, paths) {
1053
+ p.paths ??= new Map();
1054
+ const ask = [...new Set(paths)].filter((x) => !p.paths.has(x));
1055
+ for (let i = 0; i < ask.length; i += MAX_ASK) {
1056
+ const batch = ask.slice(i, i + MAX_ASK);
1057
+ const q = batch.map((x) => `p=${encodeURIComponent(x)}`).join("&");
1058
+ const got = fetch(`${agentPath(r, "paths")}?${q}`, {
1059
+ headers: { accept: "json" },
1060
+ })
1061
+ .then((res) => (res.ok ? res.json() : null))
1062
+ .catch(() => null);
1063
+ for (const x of batch)
1064
+ p.paths.set(
1065
+ x,
1066
+ got.then((b) => {
1067
+ const v = b?.paths?.[x];
1068
+ return typeof v === "string" ? v : null;
1069
+ }),
1070
+ );
1071
+ }
1072
+ const out = new Map();
1073
+ for (const x of paths) out.set(x, await p.paths.get(x));
1074
+ return out;
1075
+ }
1076
+
1077
+ /** xterm asks for line `y`'s links (1-based): its paths that exist. */
1078
+ async function providePathLinks(p, y, done) {
1079
+ try {
1080
+ const r = rowOf(p.id);
1081
+ const line = p.term?.buffer.active.getLine(y - 1)?.translateToString(true);
1082
+ if (r?.kind !== "agent" || !r.agent || !line) return done(undefined);
1083
+ const found = findPaths(line);
1084
+ if (!found.length) return done(undefined);
1085
+ const abs = await pathLinks(
1086
+ p,
1087
+ r,
1088
+ found.map((m) => m.path),
1089
+ );
1090
+ const links = found
1091
+ .filter((m) => abs.get(m.path))
1092
+ .map((m) => ({
1093
+ text: m.text,
1094
+ range: { start: { x: m.start + 1, y }, end: { x: m.end, y } },
1095
+ decorations: { pointerCursor: true, underline: true },
1096
+ activate: (e) => void openPath(e, abs.get(m.path), m.line, m.col),
1097
+ }));
1098
+ done(links.length ? links : undefined);
1099
+ } catch {
1100
+ done(undefined);
1101
+ }
1102
+ }
1103
+
1104
+ /**
1105
+ * A path link clicked: with ⌘ (Ctrl elsewhere), the editor in Settings
1106
+ * (editor_open) at the line, by its URL scheme; none copies the path.
1107
+ */
1108
+ async function openPath(e, abs, line, col) {
1109
+ if (!linkModifier(e) || !abs) return;
1110
+ e?.preventDefault?.();
1111
+ const url = editorUrl(st.editorOpen, abs, line, col);
1112
+ if (url) {
1113
+ window.open(url, "_self");
1114
+ return;
1115
+ }
1116
+ try {
1117
+ await navigator.clipboard.writeText(abs);
1118
+ window.denShell?.toast?.(t("ag.copied"));
1119
+ } catch (err) {
1120
+ window.denShell?.errorToast?.(err);
1121
+ }
1122
+ }
1123
+
1124
+ async function openPane(id, { front = true } = {}) {
699
1125
  if (st.panes.has(id)) {
700
1126
  st.panes.get(id).term?.focus();
701
1127
  return;
702
1128
  }
1129
+ if (front) st.cur = id;
703
1130
  if (st.open.length >= MAX_PANES) {
704
1131
  const oldest = st.open[0];
705
1132
  window.denShell?.toast?.(
@@ -710,7 +1137,10 @@ async function openPane(id) {
710
1137
  const el = document.createElement("section");
711
1138
  el.className = "pane";
712
1139
  el.dataset.id = id;
713
- el.innerHTML = `<div class="bar2"></div><div class="term"></div>`;
1140
+ // xterm opens in .tbox, the .term's content box: the fit measures its
1141
+ // parent's height and width as they are, so the .term's padding must
1142
+ // not be in it (AG-4b: every terminal fits its pane).
1143
+ el.innerHTML = `<div class="bar2"></div><div class="term"><div class="tbox"></div></div>`;
714
1144
  const p = {
715
1145
  id,
716
1146
  el,
@@ -745,18 +1175,50 @@ async function openPane(id) {
745
1175
  scrollback: 5000,
746
1176
  theme: termTheme(),
747
1177
  allowProposedApi: false,
1178
+ // OSC 8 links (AG-4b): ⌘/Ctrl-click, http and https only, no confirm.
1179
+ linkHandler: {
1180
+ activate: (e, uri) => void openWeb(e, uri),
1181
+ allowNonHttpProtocols: false,
1182
+ },
748
1183
  });
749
1184
  const fit = new lib.FitAddon();
750
1185
  term.loadAddon(fit);
751
- term.open(el.querySelector(".term"));
1186
+ // Plain URLs (AG-4b): the same rule as OSC 8.
1187
+ term.loadAddon(new lib.WebLinksAddon((e, uri) => void openWeb(e, uri)));
1188
+ term.open(el.querySelector(".term .tbox"));
752
1189
  p.term = term;
753
1190
  p.fit = fit;
754
1191
  const enc = new TextEncoder();
755
- term.onData((d) => {
1192
+ const typeIn = (d) => {
756
1193
  if (p.sock?.readyState !== WebSocket.OPEN) return;
757
1194
  // The one typing wins: another viewer resized the PTY since.
758
1195
  if (outOfStep(p)) claimSize(p);
759
1196
  p.sock.send(enc.encode(d));
1197
+ };
1198
+ term.onData(typeIn);
1199
+ // AG-4b: Shift+Enter is the runtime's soft newline, never a CR; the
1200
+ // layout keys (⌃⇧1-3) are the page's, never the agent's.
1201
+ term.attachCustomKeyEventHandler((e) => {
1202
+ if (e.ctrlKey && e.shiftKey && /^Digit[123]$/.test(e.code ?? ""))
1203
+ return false;
1204
+ if (
1205
+ e.key === "Enter" &&
1206
+ e.shiftKey &&
1207
+ !e.ctrlKey &&
1208
+ !e.altKey &&
1209
+ !e.metaKey
1210
+ ) {
1211
+ if (e.isComposing) return true;
1212
+ e.preventDefault();
1213
+ if (softNewlineKey(e)) typeIn(softNewlineOf(id));
1214
+ return false;
1215
+ }
1216
+ return true;
1217
+ });
1218
+ // File paths (AG-4b): links only where the server says they exist in
1219
+ // the agent's workspace.
1220
+ term.registerLinkProvider({
1221
+ provideLinks: (y, done) => void providePathLinks(p, y, done),
760
1222
  });
761
1223
  term.onResize(() => sendSize(p));
762
1224
  // Focused (a click, the window coming back): this pane's size.
@@ -777,6 +1239,8 @@ function closePane(id, { quiet = false } = {}) {
777
1239
  p.closed = true;
778
1240
  st.panes.delete(id);
779
1241
  st.open = st.open.filter((x) => x !== id);
1242
+ st.side.delete(id);
1243
+ if (st.cur === id) st.cur = st.open.at(-1) ?? null;
780
1244
  try {
781
1245
  p.sock?.close(1000, "closed");
782
1246
  } catch {
@@ -1131,6 +1595,16 @@ function translateStatic(root) {
1131
1595
  set("#ag-r-t", "ag.runtime");
1132
1596
  set("#ag-cancel", "ag.cancel");
1133
1597
  set("#ag-go", "ag.go");
1598
+ for (const b of root.querySelectorAll("[data-layouts] button")) {
1599
+ b.textContent = t(`ag.l.${b.dataset.layout}`);
1600
+ b.title = t("ag.l.tip", {
1601
+ name: b.textContent,
1602
+ key: `⌃⇧${LAYOUTS.indexOf(b.dataset.layout) + 1}`,
1603
+ });
1604
+ }
1605
+ root
1606
+ .querySelector("[data-layouts]")
1607
+ ?.setAttribute("aria-label", t("ag.layout"));
1134
1608
  const h = root.querySelector("#ag-dlg-h");
1135
1609
  if (h) h.textContent = h.textContent.replace(/^\+\s*/, "");
1136
1610
  }
@@ -1143,6 +1617,7 @@ export async function mountAgentsPage(root) {
1143
1617
  if (window.denShell?.apiOff?.()) return;
1144
1618
  st.open = [];
1145
1619
  const wanted = openFromUrl(location.search);
1620
+ const linked = layoutFromUrl(location.search);
1146
1621
  const startAsked = new URLSearchParams(location.search).get("start") === "1";
1147
1622
 
1148
1623
  root.querySelector("#agl").addEventListener("click", (e) => {
@@ -1166,14 +1641,29 @@ export async function mountAgentsPage(root) {
1166
1641
  const b = e.target.closest(".agr");
1167
1642
  if (!b) return;
1168
1643
  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);
1644
+ void pick(b.dataset.id, e.shiftKey || e.metaKey || e.ctrlKey);
1645
+ });
1646
+ root.querySelector("[data-layouts]")?.addEventListener("click", (e) => {
1647
+ const b = e.target.closest("button[data-layout]");
1648
+ if (b) setLayout(b.dataset.layout);
1649
+ });
1650
+ root.querySelector("#agr").addEventListener("dblclick", (e) => {
1651
+ const tab = e.target.closest('[role="tab"]');
1652
+ if (tab) renameTab(tab);
1172
1653
  });
1173
1654
  root.querySelector("#agr").addEventListener("click", (e) => {
1655
+ const tab = e.target.closest('[role="tab"]');
1656
+ if (tab) {
1657
+ if (st.cur === tab.dataset.id) return;
1658
+ st.cur = tab.dataset.id;
1659
+ paintLayout();
1660
+ paintList();
1661
+ tab.focus();
1662
+ return;
1663
+ }
1174
1664
  const b = e.target.closest("button[data-act]");
1175
1665
  if (!b) return;
1176
- const id = b.closest(".pane")?.dataset.id;
1666
+ const id = b.closest(".pane")?.dataset.id ?? b.dataset.id;
1177
1667
  const p = id && st.panes.get(id);
1178
1668
  if (!p) return;
1179
1669
  if (b.dataset.act === "close") closePane(id);
@@ -1217,6 +1707,33 @@ export async function mountAgentsPage(root) {
1217
1707
  attributeFilter: ["data-mode", "data-theme", "class"],
1218
1708
  });
1219
1709
  if (!st.docWired) wireDocument();
1710
+ // ⌃⇧1 / ⌃⇧2 / ⌃⇧3 on every OS: the layouts, even from a terminal (whose
1711
+ // key handler never passes them to the agent). ⌘1-9 and Ctrl+1-9 are
1712
+ // the browser's (its tabs): the page leaves them alone.
1713
+ document.addEventListener(
1714
+ "keydown",
1715
+ (e) => {
1716
+ const i = layoutKeyIndex(e);
1717
+ if (i < 0 || st.root.querySelector("#ag-dlg")?.open) return;
1718
+ e.preventDefault();
1719
+ e.stopPropagation();
1720
+ setLayout(LAYOUTS[i]);
1721
+ },
1722
+ true,
1723
+ );
1724
+ // ⌘K: "Layout: Focus", "Layout: Tabs", "Layout: Tile".
1725
+ for (const l of LAYOUTS)
1726
+ window.denShell?.registerCommand?.({
1727
+ id: `agents-layout:${l}`,
1728
+ label: () => t("ag.pal.layout", { name: t(`ag.l.${l}`) }),
1729
+ keywords: ["layout", "agents", l, t(`ag.l.${l}`)],
1730
+ run: () => setLayout(l),
1731
+ });
1732
+ // The phone width comes and goes: Tabs there, the person's layout here.
1733
+ window.matchMedia?.(PHONE).addEventListener?.("change", () => {
1734
+ paintLayout();
1735
+ paintList();
1736
+ });
1220
1737
  window.addEventListener("pagehide", () => {
1221
1738
  for (const p of st.panes.values()) {
1222
1739
  p.closed = true;
@@ -1228,7 +1745,9 @@ export async function mountAgentsPage(root) {
1228
1745
  }
1229
1746
  });
1230
1747
 
1231
- await loadScope();
1748
+ const [, saved] = await Promise.all([loadScope(), savedLayout()]);
1749
+ // A link's &layout= wins for this page; else the person's.
1750
+ st.layout = linked ?? saved;
1232
1751
  await refresh();
1233
1752
  paintLayout();
1234
1753
  // An id the list no longer has (removed, another distribution's) is