agent-yes 1.236.0 → 1.237.0
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/dist/{JsonlStore-CWOA3aDH.js → JsonlStore-CPGIR0Wy.js} +1 -1
- package/dist/SUPPORTED_CLIS-C0RwjYrz.js +11 -0
- package/dist/{SUPPORTED_CLIS-C7bkkXue.js → SUPPORTED_CLIS-CDrJz9Xo.js} +2 -2
- package/dist/{agent-yes.config-BhjzWavn.js → agent-yes.config-DPtMM0S-.js} +2 -2
- package/dist/{agentShare-BxAaZ-UR.js → agentShare-Cl_J3dfx.js} +9 -9
- package/dist/{agentYesHome-CtHb5b71.js → agentYesHome-CtmsY6I-.js} +1 -1
- package/dist/browser-B3dOptjL.js +311 -0
- package/dist/browser-BRMyG4JA.js +13514 -0
- package/dist/callback-CFbkvqTh.js +12 -0
- package/dist/{callback-B1AWp2tC.js → callback-D0_AeyN1.js} +6 -6
- package/dist/{callbackCore-CcbVa8r2.js → callbackCore-CvmGSvrj.js} +1 -1
- package/dist/{callbackCore-Me97qn68.js → callbackCore-DAYcemCC.js} +1 -1
- package/dist/{channels-Lsot9pyU.js → channels-BiGUYTuz.js} +2 -2
- package/dist/{channels-DHXLnU4r.js → channels-CMNMFJlY.js} +6 -6
- package/dist/channels.js +6 -310
- package/dist/chunk-cZBX9HZv.js +20 -0
- package/dist/cli.js +11 -11
- package/dist/{configShared-BXz2vi4Q.js → configShared-9wVlbPyC.js} +1 -1
- package/dist/{cwdConflictWarn-ArYYtOGy.js → cwdConflictWarn-DWHWlBxS.js} +2 -2
- package/dist/{cwdDeprecation-BGX20-Fe.js → cwdDeprecation-Cd8Ykm2t.js} +1 -1
- package/dist/{e2e-CYlKpfxa.js → e2e-CEIdJGZ7.js} +1 -1
- package/dist/{expose-ByT4ef7C.js → expose-DROTwbyU.js} +1 -1
- package/dist/{forkNested-Bai0Q7BL.js → forkNested-DqPisz9d.js} +2 -2
- package/dist/{globalPidIndex-Ca4sTr1b.js → globalPidIndex-D9DOSd-c.js} +1 -1
- package/dist/index.js +7 -7
- package/dist/{messageLog-zVGWLX7w.js → messageLog-C-YIzhRj.js} +1 -1
- package/dist/{nodeRuntime-fVRbz6w6.js → nodeRuntime-Bf88NPqj.js} +2 -2
- package/dist/{notifyDaemon-Cxb0-5a0.js → notifyDaemon-D_8TRVHt.js} +8 -8
- package/dist/{openBrowser-DL-EO8Hi.js → openBrowser-ChLcPELv.js} +1 -1
- package/dist/{peer-68ZwnxLs.js → peer-B5JKF4BP.js} +3 -3
- package/dist/peer-D5lr1Afv.js +4 -0
- package/dist/{pidStore-Hjba3K6Q.js → pidStore-6EZd5DUv.js} +4 -4
- package/dist/pidStore-bCVS2Ldt.js +6 -0
- package/dist/{reaper-iQXyufgp.js → reaper-DP49VCKx.js} +2 -2
- package/dist/{reaper-pJpMYKmj.js → reaper-IhkB_RO0.js} +1 -1
- package/dist/{remotes-CF1LJLxY.js → remotes-BClMgQZW.js} +3 -3
- package/dist/{remotes-BDdfhL6N.js → remotes-Bem6WIyU.js} +3 -3
- package/dist/{runningLock-BFk3Mo8K.js → runningLock-DBoONoM-.js} +1 -1
- package/dist/{rustBinary-CFSG7it2.js → rustBinary-Bb9qJZ5w.js} +2 -2
- package/dist/{schedule-CXHdiUHa.js → schedule-B8Erb3y6.js} +11 -11
- package/dist/{serve-DEEwIlVV.js → serve-QejbNTr-.js} +187 -43
- package/dist/{setup-QfDtv5tw.js → setup-Dzi5Cc3g.js} +3 -3
- package/dist/{share-CDER9qNq.js → share--aHaPSjt.js} +2 -2
- package/dist/{share-D4K0JnHg.js → share-B9u8-I3y.js} +2 -2
- package/dist/{spawnGate-PwzImpOw.js → spawnGate-Bo_LFgt9.js} +3 -3
- package/dist/spawnGate-CuWCd2im.js +5 -0
- package/dist/{store-BXJERNbW.js → store-Ck0XRn-K.js} +1 -1
- package/dist/{subcommands-_Ci82sb1.js → subcommands-DFr-5FOL.js} +26 -21
- package/dist/{subcommands-m2DRpvHt.js → subcommands-ymJWO2iv.js} +7 -7
- package/dist/terminal-vNeFNLT8.js +138 -0
- package/dist/terminal.js +3 -0
- package/dist/{todoAutomation-x6guaQXc.js → todoAutomation-Buo457VO.js} +2 -2
- package/dist/{todoCli-CAMVVz9u.js → todoCli-CNNR9Czu.js} +4 -4
- package/dist/{tray-CF8xRQqt.js → tray-D5qFaqlS.js} +2 -2
- package/dist/{ts-D1FDmhc-.js → ts-Dd1FK7sR.js} +11 -28
- package/dist/{versionChecker-DJEtU1JU.js → versionChecker-XfEBINhH.js} +2 -2
- package/dist/{webrtcLink-CEYNs0pt.js → webrtcLink-VQhWmKOw.js} +2 -2
- package/dist/{webrtcRemote-CNC_bMH6.js → webrtcRemote-DJDtCTpj.js} +3 -3
- package/dist/widgets.js +7 -0
- package/dist/{workspaceConfig-CuGjX2_R.js → workspaceConfig-B7HaQ-MI.js} +2 -2
- package/dist/{ws-C7B4xWQl.js → ws-Dun8gGiV.js} +9 -9
- package/package.json +13 -1
- package/ts/serve.ts +144 -5
- package/ts/subcommands.ts +6 -0
- package/ts/termToken.spec.ts +44 -0
- package/ts/termToken.ts +85 -0
- package/ts/terminal/browser.ts +607 -0
- package/ts/terminal.spec.ts +50 -0
- package/ts/terminal.ts +245 -0
- package/ts/widgets.ts +14 -0
- package/dist/SUPPORTED_CLIS-BDXbzEWF.js +0 -11
- package/dist/callback-Dtk0wMsl.js +0 -12
- package/dist/peer-CX4qQx5U.js +0 -4
- package/dist/pidStore-Cp3HekUh.js +0 -6
- package/dist/spawnGate-C21TL4f2.js +0 -5
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// Browser terminal client: `import AyTerminal from "agent-yes/terminal"`.
|
|
2
|
+
//
|
|
3
|
+
// A self-contained Shadow-DOM widget that renders a LIVE agent-session terminal
|
|
4
|
+
// on any page — the terminal sibling of AyChannel (channels/browser.ts). It
|
|
5
|
+
// streams the agent's raw PTY bytes from the serving `ay serve` daemon's SSE
|
|
6
|
+
// endpoint (`GET /api/tail/<pid>?raw=1`) into an xterm.js terminal.
|
|
7
|
+
//
|
|
8
|
+
// const t = new AyTerminal({ pid: "12345" }); // token auto-discovered
|
|
9
|
+
// t.mount(document.getElementById("term")); // inline, or t.mount() to float
|
|
10
|
+
//
|
|
11
|
+
// READ-ONLY by default: the grid is sized to the agent's NATIVE PTY (via
|
|
12
|
+
// /api/size) and CSS-scaled to fit, and NO resize is ever pushed back, so a viewer
|
|
13
|
+
// can't reflow the agent's real terminal (the rgui read-only model). INTERACTIVE
|
|
14
|
+
// (an explicit opt-in gated on a send-capable token) instead lets the viewer type
|
|
15
|
+
// (→ /api/send) and drag-resize to renegotiate the real PTY (→ /api/resize); it
|
|
16
|
+
// fails closed to a mirror on a 403.
|
|
17
|
+
//
|
|
18
|
+
// The floating widget is a draggable, resizable window with OS-native min/max
|
|
19
|
+
// controls; `transparent:true` lets it float over page content. xterm + its CSS
|
|
20
|
+
// are bundled/inlined so the widget is self-contained — no CDN <script> the
|
|
21
|
+
// embedding page's CSP must allow. Its stylesheet is injected into the Shadow root
|
|
22
|
+
// (`:host { all: initial }` isolates the page's styles from it).
|
|
23
|
+
//
|
|
24
|
+
// DOM/xterm are accessed as `any` throughout because the Node tsconfig has no DOM
|
|
25
|
+
// lib (xterm's own types reference DOM types); the widget only ever runs in a browser.
|
|
26
|
+
import { Terminal } from "@xterm/xterm";
|
|
27
|
+
import { FitAddon } from "@xterm/addon-fit";
|
|
28
|
+
|
|
29
|
+
export interface AyTerminalInfo {
|
|
30
|
+
/** Agent pid or any keyword `ay` can resolve (passed to /api/tail/<keyword>). */
|
|
31
|
+
pid: string | number;
|
|
32
|
+
/** Daemon origin, e.g. "https://box.local:8787". Default "" = same-origin (relative URLs). */
|
|
33
|
+
origin?: string;
|
|
34
|
+
/** Serve token. Default: discovered from window.AY_TERM_TOKEN, the page #k= hash, or localStorage. */
|
|
35
|
+
token?: string;
|
|
36
|
+
/** Interactive when false (type to the agent + drag-resize the PTY); read-only mirror when true (default). */
|
|
37
|
+
readOnly?: boolean;
|
|
38
|
+
/** Header label; default a rich title fetched from /api/ls, falling back to `#<pid>`. */
|
|
39
|
+
title?: string;
|
|
40
|
+
/** Semi-transparent panel + xterm allowTransparency, so the terminal floats over page content. */
|
|
41
|
+
transparent?: boolean;
|
|
42
|
+
/** Window-control button side: "left" (macOS traffic-light) | "right" (Win/Linux) | "auto" (OS-detect, default). */
|
|
43
|
+
controls?: "left" | "right" | "auto";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AyTerminal {
|
|
47
|
+
readonly pid: string;
|
|
48
|
+
readonly origin: string;
|
|
49
|
+
readonly readOnly: boolean;
|
|
50
|
+
readonly transparent: boolean;
|
|
51
|
+
title: string;
|
|
52
|
+
private token: string;
|
|
53
|
+
private controls: "left" | "right" | "auto";
|
|
54
|
+
private term?: any; // xterm Terminal
|
|
55
|
+
private fit?: any; // FitAddon (interactive PTY-fit only)
|
|
56
|
+
private es?: any; // EventSource
|
|
57
|
+
private widget?: any;
|
|
58
|
+
private badgeEl?: any; // the interactive/read-only header badge (flipped on a 403)
|
|
59
|
+
private started = false;
|
|
60
|
+
private writable = false; // flips false on the first /api/send|resize 403 (token is read-only)
|
|
61
|
+
private resizeTimer: any = null;
|
|
62
|
+
|
|
63
|
+
constructor(info: string | number | AyTerminalInfo) {
|
|
64
|
+
const o: AyTerminalInfo =
|
|
65
|
+
typeof info === "string" || typeof info === "number" ? { pid: info } : info;
|
|
66
|
+
if (o.pid === undefined || o.pid === null || o.pid === "")
|
|
67
|
+
throw new Error("AyTerminal: need a pid/keyword");
|
|
68
|
+
this.pid = String(o.pid);
|
|
69
|
+
this.origin = (o.origin ?? "").replace(/\/+$/, ""); // "" ⇒ same-origin relative URLs
|
|
70
|
+
this.readOnly = o.readOnly !== false; // default true
|
|
71
|
+
this.transparent = o.transparent === true;
|
|
72
|
+
this.controls = o.controls ?? "auto";
|
|
73
|
+
this.token = o.token ?? discoverToken();
|
|
74
|
+
this.title = o.title ?? `#${this.pid}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** URL to a daemon API path with the token appended as a query param (EventSource can't set headers). */
|
|
78
|
+
private url(path: string): string {
|
|
79
|
+
const p = this.token
|
|
80
|
+
? `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.token)}`
|
|
81
|
+
: path;
|
|
82
|
+
return this.origin + p;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create the xterm, size its grid to the agent's native PTY, and subscribe to
|
|
87
|
+
* the raw SSE stream. `el` is the element the terminal renders into.
|
|
88
|
+
*/
|
|
89
|
+
async start(el: any): Promise<void> {
|
|
90
|
+
if (this.started) return;
|
|
91
|
+
this.started = true;
|
|
92
|
+
const g = globalThis as any;
|
|
93
|
+
this.writable = !this.readOnly;
|
|
94
|
+
this.term = new Terminal({
|
|
95
|
+
fontSize: 13,
|
|
96
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
97
|
+
theme: this.transparent ? { ...TERM_THEME, background: "rgba(13,17,23,0)" } : TERM_THEME,
|
|
98
|
+
allowTransparency: this.transparent, // construction-time — can't be toggled later
|
|
99
|
+
disableStdin: this.readOnly,
|
|
100
|
+
cursorBlink: !this.readOnly,
|
|
101
|
+
scrollback: 2000,
|
|
102
|
+
convertEol: false,
|
|
103
|
+
});
|
|
104
|
+
// Interactive viewers reflow the real PTY, so load the fit addon to propose
|
|
105
|
+
// cols/rows from the container size; read-only viewers never do (native grid).
|
|
106
|
+
if (!this.readOnly) {
|
|
107
|
+
this.fit = new FitAddon();
|
|
108
|
+
this.term.loadAddon(this.fit);
|
|
109
|
+
}
|
|
110
|
+
this.term.open(el);
|
|
111
|
+
|
|
112
|
+
// Size the grid to the agent's own PTY so absolute-cursor raw bytes land in
|
|
113
|
+
// the right cells; we only READ /api/size and never POST a resize here.
|
|
114
|
+
try {
|
|
115
|
+
const res = await g.fetch(this.url(`/api/size/${encodeURIComponent(this.pid)}`), {
|
|
116
|
+
headers: { accept: "application/json" },
|
|
117
|
+
});
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
const sz = (await res.json()) as { cols?: number; rows?: number };
|
|
120
|
+
if (sz?.cols && sz?.rows) this.term.resize(sz.cols, sz.rows);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
/* keep the default grid — the stream still redraws on the next full repaint */
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// SSE: each `data:` frame is a JSON-encoded raw chunk (`: ping` keepalives are
|
|
127
|
+
// non-JSON and ignored). Replays the last ~64KB on connect so the view converges.
|
|
128
|
+
const es = new g.EventSource(this.url(`/api/tail/${encodeURIComponent(this.pid)}?raw=1`));
|
|
129
|
+
es.onmessage = (e: any) => {
|
|
130
|
+
try {
|
|
131
|
+
this.term!.write(JSON.parse(e.data) as string);
|
|
132
|
+
} catch {
|
|
133
|
+
/* keepalive / non-JSON frame */
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
this.es = es;
|
|
137
|
+
|
|
138
|
+
// Interactive input (opt-in): forward keystrokes to the agent's stdin as raw
|
|
139
|
+
// bytes (code:"none" — the terminal itself sends \r). Fails closed to a mirror
|
|
140
|
+
// on a 403 (a read-only token), like the rgui embed's attachStdin/onDenied.
|
|
141
|
+
if (!this.readOnly) {
|
|
142
|
+
this.term.onData((data: string) => {
|
|
143
|
+
if (!this.writable) return;
|
|
144
|
+
void g
|
|
145
|
+
.fetch(this.url("/api/send"), {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ keyword: this.pid, msg: data, code: "none" }),
|
|
149
|
+
})
|
|
150
|
+
.then((r: any) => {
|
|
151
|
+
if (r.status === 403 || r.status === 401) this.revokeWrite();
|
|
152
|
+
})
|
|
153
|
+
.catch(() => {
|
|
154
|
+
/* transient network error — keep trying on the next keystroke */
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A write path (send/resize) was refused (403/401) — the token is read-only.
|
|
162
|
+
* Fail closed: stop sending, disable stdin, and flip the header badge so the
|
|
163
|
+
* viewer SEES it reverted to a mirror (grocy: the badge must reflect the state).
|
|
164
|
+
*/
|
|
165
|
+
private revokeWrite(): void {
|
|
166
|
+
if (!this.writable) return; // already reverted — flip once
|
|
167
|
+
this.writable = false;
|
|
168
|
+
if (this.term) {
|
|
169
|
+
this.term.options.disableStdin = true;
|
|
170
|
+
this.term.options.cursorBlink = false;
|
|
171
|
+
}
|
|
172
|
+
if (this.badgeEl) {
|
|
173
|
+
this.badgeEl.className = "ro";
|
|
174
|
+
this.badgeEl.textContent = "read-only";
|
|
175
|
+
this.badgeEl.title = "reverted to read-only — this token can't write to the agent";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Push a PTY resize to the agent (interactive only). Debounced, gated on a
|
|
181
|
+
* still-writable token; a 403 reverts to read-only.
|
|
182
|
+
*/
|
|
183
|
+
private pushPtyResize(cols: number, rows: number): void {
|
|
184
|
+
if (this.readOnly || !this.writable) return;
|
|
185
|
+
const g = globalThis as any;
|
|
186
|
+
clearTimeout(this.resizeTimer);
|
|
187
|
+
this.resizeTimer = setTimeout(() => {
|
|
188
|
+
void g
|
|
189
|
+
.fetch(this.url(`/api/resize/${encodeURIComponent(this.pid)}`), {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: { "content-type": "application/json" },
|
|
192
|
+
body: JSON.stringify({ cols, rows }),
|
|
193
|
+
})
|
|
194
|
+
.then((r: any) => {
|
|
195
|
+
if (r.status === 403 || r.status === 401) this.revokeWrite();
|
|
196
|
+
})
|
|
197
|
+
.catch(() => {});
|
|
198
|
+
}, 200);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Tear down the stream, the terminal, and the mounted widget. */
|
|
202
|
+
close(): void {
|
|
203
|
+
this.es?.close();
|
|
204
|
+
this.es = undefined;
|
|
205
|
+
this.term?.dispose();
|
|
206
|
+
this.term = undefined;
|
|
207
|
+
if (this.widget) {
|
|
208
|
+
this.widget.remove();
|
|
209
|
+
this.widget = undefined;
|
|
210
|
+
}
|
|
211
|
+
this.started = false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Render the widget. With a `target` element it embeds inline, filling that
|
|
216
|
+
* element (the report-page case); with no target it mounts a floating,
|
|
217
|
+
* draggable, resizable window with a corner bubble. Auto-calls start().
|
|
218
|
+
*/
|
|
219
|
+
mount(target?: any, opts?: { open?: boolean }): any {
|
|
220
|
+
if (this.widget) return this.widget;
|
|
221
|
+
const g = globalThis as any;
|
|
222
|
+
const doc: any = g.document;
|
|
223
|
+
const host = doc.createElement("div");
|
|
224
|
+
host.setAttribute("data-ayterminal", this.pid);
|
|
225
|
+
const root = host.attachShadow({ mode: "open" });
|
|
226
|
+
const inline = !!target;
|
|
227
|
+
const side = controlsSide(this.controls);
|
|
228
|
+
root.innerHTML = widgetHtml({ inline, title: this.title, readOnly: this.readOnly, side });
|
|
229
|
+
this.widget = host;
|
|
230
|
+
if (opts?.open) host.setAttribute("data-open", "1");
|
|
231
|
+
(target ?? doc.body).appendChild(host);
|
|
232
|
+
|
|
233
|
+
const panel = root.getElementById("panel")!;
|
|
234
|
+
const inner = root.getElementById("inner")!;
|
|
235
|
+
const wrap = root.getElementById("wrap")!;
|
|
236
|
+
const titleEl = root.getElementById("title")!;
|
|
237
|
+
this.badgeEl = root.getElementById("badge");
|
|
238
|
+
if (this.transparent) panel.classList.add("transparent");
|
|
239
|
+
|
|
240
|
+
// Reflow: read-only CSS-scales the native grid to fit; interactive fits the
|
|
241
|
+
// grid to the container and pushes the new PTY size to the agent.
|
|
242
|
+
const reflow = () => {
|
|
243
|
+
if (this.readOnly) {
|
|
244
|
+
const xt = inner.querySelector(".xterm") as any;
|
|
245
|
+
if (!xt || !xt.offsetWidth || !xt.offsetHeight) return;
|
|
246
|
+
const s = Math.min(
|
|
247
|
+
1,
|
|
248
|
+
wrap.clientWidth / xt.offsetWidth,
|
|
249
|
+
wrap.clientHeight / xt.offsetHeight,
|
|
250
|
+
);
|
|
251
|
+
inner.style.transform = `scale(${s})`;
|
|
252
|
+
} else if (this.fit) {
|
|
253
|
+
const d = this.fit.proposeDimensions?.();
|
|
254
|
+
if (d?.cols && d?.rows && this.term) {
|
|
255
|
+
this.term.resize(d.cols, d.rows);
|
|
256
|
+
this.pushPtyResize(d.cols, d.rows);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (inline) {
|
|
262
|
+
try {
|
|
263
|
+
const cs = g.getComputedStyle?.(target);
|
|
264
|
+
if (cs && cs.position === "static") target.style.position = "relative";
|
|
265
|
+
} catch {
|
|
266
|
+
/* no getComputedStyle */
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
inner.style.inset = this.readOnly ? "" : "0"; // interactive fills the wrap (no scale)
|
|
270
|
+
this.wireWindow(root, panel, doc, reflow);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Reflow on container size changes (drag-resize, maximize, viewport resize).
|
|
274
|
+
try {
|
|
275
|
+
new g.ResizeObserver(() => reflow()).observe(wrap);
|
|
276
|
+
} catch {
|
|
277
|
+
/* no ResizeObserver — fall back to the window listener below */
|
|
278
|
+
}
|
|
279
|
+
g.addEventListener?.("resize", reflow);
|
|
280
|
+
|
|
281
|
+
void (async () => {
|
|
282
|
+
await this.start(inner);
|
|
283
|
+
void this.applyRichTitle(titleEl);
|
|
284
|
+
(g.requestAnimationFrame ?? ((cb: () => void) => setTimeout(cb, 16)))(() => {
|
|
285
|
+
reflow();
|
|
286
|
+
setTimeout(reflow, 150);
|
|
287
|
+
});
|
|
288
|
+
})();
|
|
289
|
+
|
|
290
|
+
return host;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Window chrome for the floating panel: restore saved geometry, drag-move by the
|
|
295
|
+
* titlebar, minimize (→ corner bubble) / maximize (→ full viewport) controls.
|
|
296
|
+
*/
|
|
297
|
+
private wireWindow(root: any, panel: any, doc: any, reflow: () => void): void {
|
|
298
|
+
const toggle = root.getElementById("toggle")!;
|
|
299
|
+
const titlebar = root.getElementById("titlebar")!;
|
|
300
|
+
const showPanel = (on: boolean) => {
|
|
301
|
+
panel.classList.toggle("open", on);
|
|
302
|
+
toggle.style.display = on ? "none" : "";
|
|
303
|
+
if (on) reflow();
|
|
304
|
+
};
|
|
305
|
+
toggle.addEventListener("click", () => showPanel(true));
|
|
306
|
+
root.getElementById("min")?.addEventListener("click", () => showPanel(false));
|
|
307
|
+
root.getElementById("max")?.addEventListener("click", () => {
|
|
308
|
+
panel.classList.toggle("max");
|
|
309
|
+
this.saveGeom(panel);
|
|
310
|
+
reflow();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Restore saved geometry (position + size), else the CSS default corner.
|
|
314
|
+
const geom = this.loadGeom();
|
|
315
|
+
if (geom) {
|
|
316
|
+
panel.style.left = geom.left + "px";
|
|
317
|
+
panel.style.top = geom.top + "px";
|
|
318
|
+
panel.style.right = "auto";
|
|
319
|
+
panel.style.bottom = "auto";
|
|
320
|
+
panel.style.width = geom.width + "px";
|
|
321
|
+
panel.style.height = geom.height + "px";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Drag-move by the titlebar (but not when a control button is the target).
|
|
325
|
+
let dragging = false;
|
|
326
|
+
let sx = 0;
|
|
327
|
+
let sy = 0;
|
|
328
|
+
let ox = 0;
|
|
329
|
+
let oy = 0;
|
|
330
|
+
titlebar.addEventListener("mousedown", (e: any) => {
|
|
331
|
+
if (e.target?.closest?.("[data-ctl]") || panel.classList.contains("max")) return;
|
|
332
|
+
const r = panel.getBoundingClientRect();
|
|
333
|
+
panel.style.left = r.left + "px";
|
|
334
|
+
panel.style.top = r.top + "px";
|
|
335
|
+
panel.style.right = "auto";
|
|
336
|
+
panel.style.bottom = "auto";
|
|
337
|
+
dragging = true;
|
|
338
|
+
sx = e.clientX;
|
|
339
|
+
sy = e.clientY;
|
|
340
|
+
ox = r.left;
|
|
341
|
+
oy = r.top;
|
|
342
|
+
e.preventDefault();
|
|
343
|
+
});
|
|
344
|
+
doc.addEventListener("mousemove", (e: any) => {
|
|
345
|
+
if (!dragging) return;
|
|
346
|
+
panel.style.left = ox + (e.clientX - sx) + "px";
|
|
347
|
+
panel.style.top = oy + (e.clientY - sy) + "px";
|
|
348
|
+
});
|
|
349
|
+
doc.addEventListener("mouseup", () => {
|
|
350
|
+
if (!dragging) return;
|
|
351
|
+
dragging = false;
|
|
352
|
+
this.saveGeom(panel);
|
|
353
|
+
});
|
|
354
|
+
// CSS `resize:both` drag ends without a mouseup we can catch on the handle, so
|
|
355
|
+
// persist geometry after any resize settles (the ResizeObserver drives reflow).
|
|
356
|
+
try {
|
|
357
|
+
let t: any = null;
|
|
358
|
+
new (globalThis as any).ResizeObserver(() => {
|
|
359
|
+
clearTimeout(t);
|
|
360
|
+
t = setTimeout(() => this.saveGeom(panel), 300);
|
|
361
|
+
}).observe(panel);
|
|
362
|
+
} catch {
|
|
363
|
+
/* no ResizeObserver */
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (opts_open(root)) showPanel(true);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private geomKey(): string {
|
|
370
|
+
return `ay29term:${this.pid}:geom`;
|
|
371
|
+
}
|
|
372
|
+
private loadGeom(): { left: number; top: number; width: number; height: number } | null {
|
|
373
|
+
try {
|
|
374
|
+
const raw = (globalThis as any).localStorage?.getItem(this.geomKey());
|
|
375
|
+
if (!raw) return null;
|
|
376
|
+
const o = JSON.parse(raw);
|
|
377
|
+
if (["left", "top", "width", "height"].every((k) => typeof o[k] === "number")) return o;
|
|
378
|
+
} catch {
|
|
379
|
+
/* ignore */
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
private saveGeom(panel: any): void {
|
|
384
|
+
try {
|
|
385
|
+
if (panel.classList.contains("max")) return; // don't persist the maximized rect
|
|
386
|
+
const r = panel.getBoundingClientRect();
|
|
387
|
+
(globalThis as any).localStorage?.setItem(
|
|
388
|
+
this.geomKey(),
|
|
389
|
+
JSON.stringify({ left: r.left, top: r.top, width: r.width, height: r.height }),
|
|
390
|
+
);
|
|
391
|
+
} catch {
|
|
392
|
+
/* ignore */
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Replace the bare `#pid` title with a rich one (repo/branch · cli) from the
|
|
398
|
+
* /api/size metadata — that route is already allowed + CORS'd for a scoped token
|
|
399
|
+
* (and bound to this pid), so no need to expose /api/ls to a page embed.
|
|
400
|
+
*/
|
|
401
|
+
private async applyRichTitle(titleEl: any): Promise<void> {
|
|
402
|
+
const g = globalThis as any;
|
|
403
|
+
try {
|
|
404
|
+
const res = await g.fetch(this.url(`/api/size/${encodeURIComponent(this.pid)}`), {
|
|
405
|
+
headers: { accept: "application/json" },
|
|
406
|
+
});
|
|
407
|
+
if (!res.ok) return;
|
|
408
|
+
const rec = (await res.json()) as { cwd?: string; cli?: string };
|
|
409
|
+
const rich = richTitle(rec);
|
|
410
|
+
if (rich && titleEl) {
|
|
411
|
+
titleEl.textContent = rich;
|
|
412
|
+
titleEl.title = `#${this.pid}${rec.cwd ? " · " + rec.cwd : ""}`; // pid + full cwd tooltip
|
|
413
|
+
this.title = rich;
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
/* keep the fallback title */
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function opts_open(root: any): boolean {
|
|
422
|
+
return root.host?.getAttribute?.("data-open") === "1";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** window.AY_TERM_TOKEN → page `#k=` hash → localStorage["ay.localToken"] (the rgui/console cache). */
|
|
426
|
+
function discoverToken(): string {
|
|
427
|
+
const g = globalThis as any;
|
|
428
|
+
if (g.AY_TERM_TOKEN) return String(g.AY_TERM_TOKEN);
|
|
429
|
+
try {
|
|
430
|
+
const parts: string[] = (g.location?.hash ?? "").slice(1).split("&");
|
|
431
|
+
const k = parts.find((s) => s.startsWith("k="));
|
|
432
|
+
if (k) return decodeURIComponent(k.slice(2));
|
|
433
|
+
const stored = g.localStorage?.getItem("ay.localToken");
|
|
434
|
+
if (stored) return stored;
|
|
435
|
+
} catch {
|
|
436
|
+
/* no DOM */
|
|
437
|
+
}
|
|
438
|
+
return "";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** OS-adaptive control side: macOS → left (traffic-light), Win/Linux → right. */
|
|
442
|
+
function controlsSide(pref: "left" | "right" | "auto"): "left" | "right" {
|
|
443
|
+
if (pref === "left" || pref === "right") return pref;
|
|
444
|
+
try {
|
|
445
|
+
const nav = (globalThis as any).navigator;
|
|
446
|
+
const plat = String(nav?.userAgentData?.platform ?? nav?.platform ?? "").toLowerCase();
|
|
447
|
+
return plat.includes("mac") ? "left" : "right";
|
|
448
|
+
} catch {
|
|
449
|
+
return "right";
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** A console-style rich title from an /api/ls record: "repo/branch · <summary>". */
|
|
454
|
+
function richTitle(rec: any): string {
|
|
455
|
+
const parts: string[] = [];
|
|
456
|
+
const rb = repoBranch(String(rec?.cwd ?? ""));
|
|
457
|
+
if (rb) parts.push(rb);
|
|
458
|
+
const summary = rec?.title ?? rec?.summary ?? rec?.name ?? rec?.last_prompt ?? rec?.prompt;
|
|
459
|
+
if (typeof summary === "string" && summary.trim()) parts.push(clip(summary.trim(), 48));
|
|
460
|
+
else if (rec?.cli) parts.push(String(rec.cli));
|
|
461
|
+
return parts.join(" · ");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** ".../snomiao/grocy/tree/main" → "grocy/main"; degrades to the last path segment. */
|
|
465
|
+
function repoBranch(cwd: string): string {
|
|
466
|
+
if (!cwd) return "";
|
|
467
|
+
const segs = cwd.split(/[/\\]/).filter(Boolean);
|
|
468
|
+
const ti = segs.lastIndexOf("tree");
|
|
469
|
+
if (ti > 0 && segs[ti + 1]) return `${segs[ti - 1]}/${segs[ti + 1]}`;
|
|
470
|
+
return segs.slice(-1)[0] ?? "";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function clip(s: string, n: number): string {
|
|
474
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const TERM_THEME = {
|
|
478
|
+
background: "#0d1117",
|
|
479
|
+
foreground: "#c9d1d9",
|
|
480
|
+
cursor: "#c9d1d9",
|
|
481
|
+
selectionBackground: "#264f78",
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Shadow-DOM markup + styles. Self-contained (xterm's own stylesheet is inlined
|
|
486
|
+
* so it applies inside the isolated shadow root). Inline mode fills the target;
|
|
487
|
+
* floating mode is a corner bubble + a draggable, resizable window.
|
|
488
|
+
*/
|
|
489
|
+
function widgetHtml(o: {
|
|
490
|
+
inline: boolean;
|
|
491
|
+
title: string;
|
|
492
|
+
readOnly: boolean;
|
|
493
|
+
side: "left" | "right";
|
|
494
|
+
}): string {
|
|
495
|
+
const esc = (s: string) =>
|
|
496
|
+
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
497
|
+
const badge = o.readOnly
|
|
498
|
+
? `<span id="badge" class="ro" title="read-only mirror">read-only</span>`
|
|
499
|
+
: `<span id="badge" class="rw" title="interactive — type & drag-resize the agent">interactive</span>`;
|
|
500
|
+
// macOS: traffic-light dots on the left; Win/Linux: _ □ buttons on the right.
|
|
501
|
+
// Inline embeds have no window semantics (no bubble/move/maximize) → no controls.
|
|
502
|
+
const controls = o.inline
|
|
503
|
+
? ""
|
|
504
|
+
: o.side === "left"
|
|
505
|
+
? `<span class="ctl mac" data-ctl>
|
|
506
|
+
<button id="min" class="dot yellow" data-ctl title="Minimize"></button>
|
|
507
|
+
<button id="max" class="dot green" data-ctl title="Maximize"></button>
|
|
508
|
+
</span>`
|
|
509
|
+
: `<span class="ctl win" data-ctl>
|
|
510
|
+
<button id="min" data-ctl title="Minimize">_</button>
|
|
511
|
+
<button id="max" data-ctl title="Maximize">□</button>
|
|
512
|
+
</span>`;
|
|
513
|
+
const titleSpan = `<span class="title" id="title">${esc(o.title)}</span>`;
|
|
514
|
+
const header =
|
|
515
|
+
!o.inline && o.side === "left"
|
|
516
|
+
? `${controls}${titleSpan}${badge}`
|
|
517
|
+
: `${titleSpan}${badge}${controls}`;
|
|
518
|
+
const chrome = o.inline
|
|
519
|
+
? `#panel { position: absolute; inset: 0; display: flex; } #titlebar { cursor: default; }`
|
|
520
|
+
: `
|
|
521
|
+
#toggle {
|
|
522
|
+
position: fixed; right: 20px; bottom: 20px; z-index: 2147483000;
|
|
523
|
+
width: 52px; height: 52px; border-radius: 50%; cursor: pointer;
|
|
524
|
+
background: #0d1117; color: #c9d1d9; font-size: 22px; box-shadow: 0 4px 16px rgba(0,0,0,.4);
|
|
525
|
+
border: 1px solid #30363d;
|
|
526
|
+
}
|
|
527
|
+
#panel {
|
|
528
|
+
position: fixed; right: 20px; bottom: 84px; z-index: 2147483000;
|
|
529
|
+
width: min(720px, 94vw); height: min(460px, 72vh); display: none;
|
|
530
|
+
resize: both; min-width: 260px; min-height: 160px;
|
|
531
|
+
}
|
|
532
|
+
#panel.open { display: flex; }
|
|
533
|
+
#panel.max { inset: 0 !important; left: 0 !important; top: 0 !important;
|
|
534
|
+
right: 0 !important; bottom: 0 !important; width: auto !important; height: auto !important;
|
|
535
|
+
border-radius: 0; resize: none; }
|
|
536
|
+
#titlebar { cursor: move; }
|
|
537
|
+
`;
|
|
538
|
+
return `
|
|
539
|
+
<style>
|
|
540
|
+
:host { all: initial; }
|
|
541
|
+
* { box-sizing: border-box; }
|
|
542
|
+
${chrome}
|
|
543
|
+
#panel { flex-direction: column; background: #0d1117; border-radius: 12px;
|
|
544
|
+
box-shadow: 0 12px 40px rgba(0,0,0,.45); overflow: hidden; border: 1px solid #30363d; }
|
|
545
|
+
#panel.transparent { background: rgba(13,17,23,.62); backdrop-filter: blur(3px); }
|
|
546
|
+
#panel.transparent #wrap { background: transparent; }
|
|
547
|
+
#titlebar { padding: 6px 10px; background: #161b22; display: flex; align-items: center; gap: 8px;
|
|
548
|
+
font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; color: #c9d1d9; user-select: none; }
|
|
549
|
+
#panel.transparent #titlebar { background: rgba(22,27,34,.7); }
|
|
550
|
+
.title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
551
|
+
.ro { font-size: 10px; opacity: .7; background: #30363d; border-radius: 9px; padding: 1px 7px; }
|
|
552
|
+
.rw { font-size: 10px; background: #1f6feb; color: #fff; border-radius: 9px; padding: 1px 7px; }
|
|
553
|
+
.ctl { display: inline-flex; align-items: center; gap: 6px; flex: none; }
|
|
554
|
+
.ctl.win button { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 4px;
|
|
555
|
+
width: 20px; height: 18px; font: 12px monospace; cursor: pointer; line-height: 1; padding: 0; }
|
|
556
|
+
.ctl.win button:hover { background: #30363d; }
|
|
557
|
+
.ctl.mac .dot { width: 12px; height: 12px; border-radius: 50%; border: none; cursor: pointer; padding: 0; }
|
|
558
|
+
.ctl.mac .dot.yellow { background: #febc2e; }
|
|
559
|
+
.ctl.mac .dot.green { background: #28c840; }
|
|
560
|
+
#wrap { flex: 1; position: relative; overflow: hidden; background: #0d1117; }
|
|
561
|
+
#inner { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
|
562
|
+
${XTERM_CSS}
|
|
563
|
+
</style>
|
|
564
|
+
${o.inline ? "" : `<button id="toggle" title="Terminal">🖥️</button>`}
|
|
565
|
+
<section id="panel">
|
|
566
|
+
<header id="titlebar">${header}</header>
|
|
567
|
+
<div id="wrap"><div id="inner"></div></div>
|
|
568
|
+
</section>
|
|
569
|
+
`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// xterm.js default stylesheet (v6), inlined so it applies inside the Shadow root.
|
|
573
|
+
// Upstream: node_modules/@xterm/xterm/css/xterm.css (MIT, xterm.js authors).
|
|
574
|
+
const XTERM_CSS = `
|
|
575
|
+
.xterm { cursor: text; position: relative; user-select: none; -ms-user-select: none; -webkit-user-select: none; }
|
|
576
|
+
.xterm.focus, .xterm:focus { outline: none; }
|
|
577
|
+
.xterm .xterm-helpers { position: absolute; top: 0; z-index: 5; }
|
|
578
|
+
.xterm .xterm-helper-textarea { padding: 0; border: 0; margin: 0; position: absolute; opacity: 0; left: -9999em; top: 0; width: 0; height: 0; z-index: -5; white-space: nowrap; overflow: hidden; resize: none; }
|
|
579
|
+
.xterm .composition-view { background: #000; color: #FFF; display: none; position: absolute; white-space: nowrap; z-index: 1; }
|
|
580
|
+
.xterm .composition-view.active { display: block; }
|
|
581
|
+
.xterm .xterm-viewport { background-color: transparent; overflow-y: scroll; cursor: default; position: absolute; right: 0; left: 0; top: 0; bottom: 0; }
|
|
582
|
+
.xterm .xterm-screen { position: relative; }
|
|
583
|
+
.xterm .xterm-screen canvas { position: absolute; left: 0; top: 0; }
|
|
584
|
+
.xterm-char-measure-element { display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em; line-height: normal; }
|
|
585
|
+
.xterm.enable-mouse-events { cursor: default; }
|
|
586
|
+
.xterm.xterm-cursor-pointer, .xterm .xterm-cursor-pointer { cursor: pointer; }
|
|
587
|
+
.xterm.column-select.focus { cursor: crosshair; }
|
|
588
|
+
.xterm .xterm-accessibility:not(.debug), .xterm .xterm-message { position: absolute; left: 0; top: 0; bottom: 0; right: 0; z-index: 10; color: transparent; pointer-events: none; }
|
|
589
|
+
.xterm .xterm-accessibility-tree:not(.debug) *::selection { color: transparent; }
|
|
590
|
+
.xterm .xterm-accessibility-tree { font-family: monospace; user-select: text; white-space: pre; }
|
|
591
|
+
.xterm .xterm-accessibility-tree > div { transform-origin: left; width: fit-content; }
|
|
592
|
+
.xterm .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
|
593
|
+
.xterm-dim { opacity: 1 !important; }
|
|
594
|
+
.xterm-underline-1 { text-decoration: underline; }
|
|
595
|
+
.xterm-underline-2 { text-decoration: double underline; }
|
|
596
|
+
.xterm-underline-3 { text-decoration: wavy underline; }
|
|
597
|
+
.xterm-underline-4 { text-decoration: dotted underline; }
|
|
598
|
+
.xterm-underline-5 { text-decoration: dashed underline; }
|
|
599
|
+
.xterm-overline { text-decoration: overline; }
|
|
600
|
+
.xterm-strikethrough { text-decoration: line-through; }
|
|
601
|
+
.xterm-screen .xterm-decoration-container .xterm-decoration { z-index: 6; position: absolute; }
|
|
602
|
+
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { z-index: 7; }
|
|
603
|
+
.xterm-decoration-overview-ruler { z-index: 8; position: absolute; top: 0; right: 0; pointer-events: none; }
|
|
604
|
+
.xterm-decoration-top { z-index: 2; position: relative; }
|
|
605
|
+
`;
|
|
606
|
+
|
|
607
|
+
export default AyTerminal;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildTermEmbedSnippet } from "./terminal.ts";
|
|
3
|
+
|
|
4
|
+
describe("buildTermEmbedSnippet", () => {
|
|
5
|
+
it("discover mode: no token in the file, imports terminal.js, mounts", () => {
|
|
6
|
+
const s = buildTermEmbedSnippet("agent-yes.com", "12345", { kind: "discover" });
|
|
7
|
+
expect(s).toContain(`import AyTerminal from "https://agent-yes.com/w/terminal.js"`);
|
|
8
|
+
expect(s).toContain(`new AyTerminal({ pid: "12345" })`);
|
|
9
|
+
expect(s).toContain(`.mount(document.getElementById("ay-term") ?? undefined)`);
|
|
10
|
+
expect(s).not.toContain("token:"); // safe default — no secret baked in
|
|
11
|
+
expect(s).not.toContain("readOnly"); // read-only is the default
|
|
12
|
+
expect(s).toContain("read-only view");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("placeholder mode: reads window.AY_TERM_TOKEN at runtime", () => {
|
|
16
|
+
const s = buildTermEmbedSnippet("agent-yes.com", "42", { kind: "placeholder" });
|
|
17
|
+
expect(s).toContain("token: window.AY_TERM_TOKEN");
|
|
18
|
+
expect(s).not.toContain('"undefined"');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("live mode: bakes the token, warns in the comment", () => {
|
|
22
|
+
const s = buildTermEmbedSnippet("h.example", "7", { kind: "live", token: "tok-abc" });
|
|
23
|
+
expect(s).toContain(`token: "tok-abc"`);
|
|
24
|
+
expect(s).toContain("LIVE token");
|
|
25
|
+
expect(s).toContain(`import AyTerminal from "https://h.example/w/terminal.js"`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("interactive opt-in sets readOnly:false and labels the comment", () => {
|
|
29
|
+
const s = buildTermEmbedSnippet("agent-yes.com", "9", {
|
|
30
|
+
kind: "live",
|
|
31
|
+
token: "t",
|
|
32
|
+
interactive: true,
|
|
33
|
+
});
|
|
34
|
+
expect(s).toContain("readOnly: false");
|
|
35
|
+
expect(s).toContain("interactive view");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("origin is threaded through for a cross-origin daemon", () => {
|
|
39
|
+
const s = buildTermEmbedSnippet("agent-yes.com", "9", {
|
|
40
|
+
kind: "discover",
|
|
41
|
+
origin: "https://box.local:8787",
|
|
42
|
+
});
|
|
43
|
+
expect(s).toContain(`origin: "https://box.local:8787"`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("escapes the pid into a JSON string (no injection into the snippet)", () => {
|
|
47
|
+
const s = buildTermEmbedSnippet("agent-yes.com", 'a"b', { kind: "discover" });
|
|
48
|
+
expect(s).toContain(`pid: "a\\"b"`);
|
|
49
|
+
});
|
|
50
|
+
});
|