@monster-orange/den 0.4.10 → 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.
- package/CHANGELOG.md +92 -0
- package/README.md +7 -7
- package/docs/distributions.md +67 -3
- package/package.json +2 -1
- package/src/shared/agents/catalog.ts +77 -0
- package/src/shared/agents/den-claude.ts +36 -8
- package/src/shared/agents/records.ts +29 -0
- package/src/shared/agents/runtime.ts +8 -0
- package/src/shared/agents/session-name.ts +110 -0
- package/src/shared/agents/transcript-follow.ts +180 -0
- package/src/shared/den-config.ts +7 -0
- package/src/ui/cli/agent-ops.ts +49 -0
- package/src/ui/server/agent-paths.ts +52 -0
- package/src/ui/server/agent-routes.ts +76 -5
- package/src/ui/server/agents-layout-pref.ts +43 -0
- package/src/ui/server/editor-open-pref.ts +41 -0
- package/src/ui/server/index.ts +22 -0
- package/src/ui/server/public/agents-page.js +539 -20
- package/src/ui/server/public/agents-section.js +1 -1
- package/src/ui/server/public/agents.html +178 -5
- package/src/ui/server/public/header.js +30 -19
- package/src/ui/server/public/i18n/es.js +23 -5
- package/src/ui/server/public/i18n/ja.js +23 -5
- package/src/ui/server/public/local-dev.html +1 -1
- package/src/ui/server/public/settings-registry.js +44 -0
- package/src/ui/server/public/shell.js +34 -20
- package/src/ui/server/public/term-input.js +129 -0
- package/src/ui/server/public/topbar.js +22 -4
- package/src/ui/server/public/workspace-picker.js +9 -6
- package/src/ui/server/route-classes.ts +11 -0
- package/src/ui/server/session-routes.ts +17 -0
- package/src/ui/server/ui-assets.ts +5 -0
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
?
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
const
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|