agent-yes 1.122.3 → 1.124.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/default.config.yaml +19 -0
- package/dist/SUPPORTED_CLIS-Cvm7yo5d.js +8 -0
- package/dist/{SUPPORTED_CLIS-BleNYXA2.js → SUPPORTED_CLIS-D_-bIOlW.js} +2 -2
- package/dist/{agent-yes.config-z-IPzH5U.js → agent-yes.config-D6ycMApr.js} +2 -65
- package/dist/cli.js +6 -6
- package/dist/configShared-C5QaNPnz.js +71 -0
- package/dist/{globalPidIndex-gZuTvTBs.js → globalPidIndex-C7r2m6s7.js} +19 -20
- package/dist/index.js +4 -4
- package/dist/pidStore-C4c2O15q.js +5 -0
- package/dist/{pidStore-B5vBu8Px.js → pidStore-CGKIhaJO.js} +5 -4
- package/dist/reaper-BLVA780B.js +3 -0
- package/dist/{reaper-Dj8R7ltI.js → reaper-BkjPN7mw.js} +24 -2
- package/dist/{remotes-CpGcTr7A.js → remotes-BRCDVnR7.js} +1 -1
- package/dist/{remotes-D2fqaRU8.js → remotes-D8GvSbhf.js} +1 -1
- package/dist/{schedule-e4f7NlA2.js → schedule-D2cn8N7o.js} +7 -7
- package/dist/{serve-CzztmZ_N.js → serve-Bo3bDXQG.js} +202 -58
- package/dist/{setup-CPyRNiIA.js → setup-CvOr258q.js} +3 -3
- package/dist/{share-CS9XVrLF.js → share-YuM6-Q6A.js} +71 -13
- package/dist/{subcommands-CQowpr1t.js → subcommands-ClVHy-xI.js} +647 -32
- package/dist/subcommands-Llf9o8nh.js +7 -0
- package/dist/{tray-DjCIyakK.js → tray-BVnJLThD.js} +1 -1
- package/dist/{ts-9GThuc3w.js → ts-DGIglR4L.js} +10 -7
- package/dist/{versionChecker-Bv9XKddN.js → versionChecker-gaQkM2Hy.js} +2 -2
- package/dist/{workspaceConfig-XP2NEWmV.js → workspaceConfig-BJO4fzEn.js} +1 -1
- package/lab/ui/console-logic.js +222 -10
- package/lab/ui/icon.svg +5 -0
- package/lab/ui/index.html +1152 -28
- package/lab/ui/landing.html +276 -0
- package/lab/ui/manifest.webmanifest +14 -0
- package/lab/ui/sw.js +56 -0
- package/package.json +5 -1
- package/ts/agentTree.spec.ts +92 -0
- package/ts/agentTree.ts +149 -0
- package/ts/configShared.ts +4 -0
- package/ts/globalPidIndex.ts +28 -20
- package/ts/idleWaiter.spec.ts +7 -1
- package/ts/index.ts +9 -0
- package/ts/lsWatch.spec.ts +61 -0
- package/ts/lsWatch.ts +94 -0
- package/ts/needsInput.spec.ts +55 -0
- package/ts/needsInput.ts +68 -0
- package/ts/pidStore.ts +3 -0
- package/ts/reaper.spec.ts +26 -2
- package/ts/reaper.ts +25 -0
- package/ts/resultEnvelope.spec.ts +43 -0
- package/ts/resultEnvelope.ts +88 -0
- package/ts/serve.ts +276 -41
- package/ts/share.ts +144 -27
- package/ts/subcommands.ts +0 -0
- package/ts/todoParse.spec.ts +68 -0
- package/ts/todoParse.ts +88 -0
- package/ts/utils.spec.ts +4 -1
- package/dist/SUPPORTED_CLIS-ClaOErso.js +0 -8
- package/dist/pidStore-7y1cTcAE.js +0 -5
- package/dist/reaper-HqcUms2d.js +0 -3
- package/dist/subcommands-KAbIcd8_.js +0 -6
package/lab/ui/index.html
CHANGED
|
@@ -2,8 +2,23 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
<meta
|
|
5
|
+
<meta
|
|
6
|
+
name="viewport"
|
|
7
|
+
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
|
|
8
|
+
/>
|
|
6
9
|
<title>agent-yes · console</title>
|
|
10
|
+
<!-- PWA: installable console under /w/ (manifest scope is /w/). The service
|
|
11
|
+
worker is network-first so it never serves a stale wire protocol — the
|
|
12
|
+
cache is only an offline-launch fallback. -->
|
|
13
|
+
<meta name="theme-color" content="#0d1117" />
|
|
14
|
+
<link rel="manifest" href="./manifest.webmanifest" />
|
|
15
|
+
<link rel="icon" href="./icon.svg" />
|
|
16
|
+
<link rel="apple-touch-icon" href="./icon.svg" />
|
|
17
|
+
<script>
|
|
18
|
+
if ("serviceWorker" in navigator) {
|
|
19
|
+
addEventListener("load", () => navigator.serviceWorker.register("./sw.js").catch(() => {}));
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
7
22
|
<style>
|
|
8
23
|
/* Palette borrowed from codehost (GitHub-dark) so the two feel like one system. */
|
|
9
24
|
:root {
|
|
@@ -23,6 +38,17 @@
|
|
|
23
38
|
--pink: #f778ba;
|
|
24
39
|
--red: #f85149;
|
|
25
40
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
41
|
+
/* Safe-area insets — non-zero only in a standalone PWA on a notched /
|
|
42
|
+
gesture-bar device (else 0, so these are harmless on desktop). Consumed
|
|
43
|
+
via max() so the surface keeps its normal padding when the inset is 0.
|
|
44
|
+
Needs <meta viewport ... viewport-fit=cover> to be reported at all. */
|
|
45
|
+
--sat: env(safe-area-inset-top);
|
|
46
|
+
--sar: env(safe-area-inset-right);
|
|
47
|
+
--sab: env(safe-area-inset-bottom);
|
|
48
|
+
--sal: env(safe-area-inset-left);
|
|
49
|
+
/* height the iOS soft keyboard overlays the viewport with (set from JS
|
|
50
|
+
via visualViewport; 0 on desktop / Android, which resizes instead). */
|
|
51
|
+
--kb: 0px;
|
|
26
52
|
}
|
|
27
53
|
/* Light theme — GitHub-light, the daylight counterpart of the dark palette
|
|
28
54
|
above. prefers-color-scheme is re-evaluated by the browser the moment the
|
|
@@ -81,7 +107,8 @@
|
|
|
81
107
|
grid-template-columns: var(--leftw, 42%) 1px 1fr;
|
|
82
108
|
grid-template-rows: minmax(0, 1fr);
|
|
83
109
|
height: 100vh;
|
|
84
|
-
|
|
110
|
+
/* subtract the iOS keyboard overlay so the composer stays above it */
|
|
111
|
+
height: calc(100dvh - var(--kb, 0px));
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
/* VSCode-style splitter: the visible divider stays 1px, but a ::before
|
|
@@ -113,7 +140,7 @@
|
|
|
113
140
|
min-height: 0;
|
|
114
141
|
}
|
|
115
142
|
.head {
|
|
116
|
-
padding: 16px 18px 10px;
|
|
143
|
+
padding: max(16px, var(--sat)) max(18px, var(--sar)) 10px max(18px, var(--sal));
|
|
117
144
|
border-bottom: 1px solid var(--line);
|
|
118
145
|
}
|
|
119
146
|
h1 {
|
|
@@ -124,6 +151,201 @@
|
|
|
124
151
|
h1 .tag {
|
|
125
152
|
color: var(--accent);
|
|
126
153
|
}
|
|
154
|
+
/* Connection-path pill in the header: at a glance, is the terminal you're
|
|
155
|
+
watching on the fast local/lan path or the slow relayed path? */
|
|
156
|
+
/* Per-peer connection-type pill in the terminal header — describes how this
|
|
157
|
+
viewer reaches the SELECTED agent's host (local/lan/wan/relay). */
|
|
158
|
+
.rhead .ctype {
|
|
159
|
+
position: relative;
|
|
160
|
+
font-size: 10px;
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
letter-spacing: 0.02em;
|
|
163
|
+
align-self: center;
|
|
164
|
+
padding: 1px 7px;
|
|
165
|
+
border-radius: 999px;
|
|
166
|
+
border: 1px solid currentColor;
|
|
167
|
+
text-transform: uppercase;
|
|
168
|
+
opacity: 0.9;
|
|
169
|
+
cursor: help;
|
|
170
|
+
}
|
|
171
|
+
.ctype[hidden] {
|
|
172
|
+
display: none;
|
|
173
|
+
}
|
|
174
|
+
/* Hover popover with the connection debug readout. */
|
|
175
|
+
.ctype .ctip {
|
|
176
|
+
display: none;
|
|
177
|
+
position: absolute;
|
|
178
|
+
top: 150%;
|
|
179
|
+
right: 0;
|
|
180
|
+
z-index: 30;
|
|
181
|
+
min-width: 240px;
|
|
182
|
+
padding: 8px 10px;
|
|
183
|
+
background: var(--panel);
|
|
184
|
+
border: 1px solid var(--line);
|
|
185
|
+
border-radius: 8px;
|
|
186
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
187
|
+
color: var(--fg);
|
|
188
|
+
font-family: var(--mono);
|
|
189
|
+
font-size: 10.5px;
|
|
190
|
+
font-weight: 400;
|
|
191
|
+
line-height: 1.6;
|
|
192
|
+
letter-spacing: 0;
|
|
193
|
+
text-transform: none;
|
|
194
|
+
text-align: left;
|
|
195
|
+
white-space: nowrap;
|
|
196
|
+
}
|
|
197
|
+
.ctype:hover .ctip {
|
|
198
|
+
display: block;
|
|
199
|
+
}
|
|
200
|
+
.ctip-h {
|
|
201
|
+
color: var(--muted);
|
|
202
|
+
margin-bottom: 5px;
|
|
203
|
+
white-space: normal;
|
|
204
|
+
}
|
|
205
|
+
.ctip-k {
|
|
206
|
+
display: inline-block;
|
|
207
|
+
width: 56px;
|
|
208
|
+
color: var(--muted);
|
|
209
|
+
}
|
|
210
|
+
/* ⋯ overflow menu in the terminal header + its dropdown. */
|
|
211
|
+
.rmenu {
|
|
212
|
+
position: relative;
|
|
213
|
+
margin-left: auto;
|
|
214
|
+
}
|
|
215
|
+
.rmenubtn {
|
|
216
|
+
background: none;
|
|
217
|
+
border: none;
|
|
218
|
+
color: var(--muted);
|
|
219
|
+
font-size: 16px;
|
|
220
|
+
line-height: 1;
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
padding: 2px 6px;
|
|
223
|
+
border-radius: 6px;
|
|
224
|
+
}
|
|
225
|
+
.rmenubtn:hover {
|
|
226
|
+
background: var(--panel);
|
|
227
|
+
color: var(--fg);
|
|
228
|
+
}
|
|
229
|
+
.rmenupanel {
|
|
230
|
+
position: absolute;
|
|
231
|
+
top: 26px;
|
|
232
|
+
right: 0;
|
|
233
|
+
z-index: 15;
|
|
234
|
+
background: var(--panel);
|
|
235
|
+
border: 1px solid var(--line);
|
|
236
|
+
border-radius: 8px;
|
|
237
|
+
padding: 4px;
|
|
238
|
+
min-width: 140px;
|
|
239
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
|
|
240
|
+
}
|
|
241
|
+
.rmenupanel[hidden] {
|
|
242
|
+
display: none;
|
|
243
|
+
}
|
|
244
|
+
.rmenuitem {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
gap: 7px;
|
|
248
|
+
padding: 5px 8px;
|
|
249
|
+
font-size: 12.5px;
|
|
250
|
+
border-radius: 6px;
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
user-select: none;
|
|
253
|
+
}
|
|
254
|
+
.rmenuitem:hover {
|
|
255
|
+
background: var(--bg);
|
|
256
|
+
}
|
|
257
|
+
.rmenuitem input {
|
|
258
|
+
margin: 0;
|
|
259
|
+
cursor: pointer;
|
|
260
|
+
}
|
|
261
|
+
/* Action items (Stop / Force-kill) reuse the menu-item look as buttons. */
|
|
262
|
+
.rmenuact {
|
|
263
|
+
width: 100%;
|
|
264
|
+
background: none;
|
|
265
|
+
border: none;
|
|
266
|
+
text-align: left;
|
|
267
|
+
font: inherit;
|
|
268
|
+
color: var(--fg);
|
|
269
|
+
}
|
|
270
|
+
.rmenuact.danger {
|
|
271
|
+
color: var(--red);
|
|
272
|
+
}
|
|
273
|
+
.rmenusep {
|
|
274
|
+
height: 1px;
|
|
275
|
+
background: var(--line);
|
|
276
|
+
margin: 4px 2px;
|
|
277
|
+
}
|
|
278
|
+
/* Terminal font-size stepper (a static row, not a hover/click target). */
|
|
279
|
+
.rmenufont {
|
|
280
|
+
justify-content: space-between;
|
|
281
|
+
cursor: default;
|
|
282
|
+
}
|
|
283
|
+
.rmenufont:hover {
|
|
284
|
+
background: none;
|
|
285
|
+
}
|
|
286
|
+
.fontstep {
|
|
287
|
+
display: inline-flex;
|
|
288
|
+
align-items: center;
|
|
289
|
+
gap: 8px;
|
|
290
|
+
}
|
|
291
|
+
.fontstep button {
|
|
292
|
+
width: 26px;
|
|
293
|
+
height: 26px;
|
|
294
|
+
background: var(--panel2);
|
|
295
|
+
border: 1px solid var(--line);
|
|
296
|
+
border-radius: 6px;
|
|
297
|
+
color: var(--fg);
|
|
298
|
+
font-size: 13px;
|
|
299
|
+
line-height: 1;
|
|
300
|
+
cursor: pointer;
|
|
301
|
+
}
|
|
302
|
+
.fontstep button:hover {
|
|
303
|
+
border-color: var(--accent);
|
|
304
|
+
color: var(--accent);
|
|
305
|
+
}
|
|
306
|
+
.fontstep #fontVal {
|
|
307
|
+
min-width: 18px;
|
|
308
|
+
text-align: center;
|
|
309
|
+
font-variant-numeric: tabular-nums;
|
|
310
|
+
color: var(--muted);
|
|
311
|
+
}
|
|
312
|
+
/* Live performance HUD — opt-in via the ⋯ menu, hidden by default. */
|
|
313
|
+
.perfhud {
|
|
314
|
+
position: absolute;
|
|
315
|
+
top: 44px;
|
|
316
|
+
right: 12px;
|
|
317
|
+
z-index: 14;
|
|
318
|
+
background: rgba(13, 17, 23, 0.82);
|
|
319
|
+
color: var(--fg);
|
|
320
|
+
border: 1px solid var(--line);
|
|
321
|
+
border-radius: 8px;
|
|
322
|
+
padding: 7px 10px;
|
|
323
|
+
font-family: var(--mono);
|
|
324
|
+
font-size: 11px;
|
|
325
|
+
line-height: 1.5;
|
|
326
|
+
pointer-events: none;
|
|
327
|
+
white-space: pre;
|
|
328
|
+
backdrop-filter: blur(3px);
|
|
329
|
+
}
|
|
330
|
+
.perfhud[hidden] {
|
|
331
|
+
display: none;
|
|
332
|
+
}
|
|
333
|
+
.perfhud b {
|
|
334
|
+
color: var(--accent);
|
|
335
|
+
font-weight: 600;
|
|
336
|
+
}
|
|
337
|
+
/* Per-peer connection-type tag on peer-group headers in the tree. */
|
|
338
|
+
.ghead .ctag {
|
|
339
|
+
font-size: 9.5px;
|
|
340
|
+
font-weight: 600;
|
|
341
|
+
text-transform: uppercase;
|
|
342
|
+
letter-spacing: 0.02em;
|
|
343
|
+
margin-left: 6px;
|
|
344
|
+
padding: 0 5px;
|
|
345
|
+
border-radius: 999px;
|
|
346
|
+
border: 1px solid currentColor;
|
|
347
|
+
opacity: 0.85;
|
|
348
|
+
}
|
|
127
349
|
.sub {
|
|
128
350
|
color: var(--muted);
|
|
129
351
|
font-size: 12.5px;
|
|
@@ -454,6 +676,8 @@
|
|
|
454
676
|
overflow-y: auto;
|
|
455
677
|
flex: 1;
|
|
456
678
|
min-height: 0;
|
|
679
|
+
/* clear the home-indicator / gesture bar so the last row stays tappable */
|
|
680
|
+
padding-bottom: var(--sab);
|
|
457
681
|
}
|
|
458
682
|
.row {
|
|
459
683
|
padding: 11px 18px;
|
|
@@ -516,6 +740,23 @@
|
|
|
516
740
|
.git.dirty {
|
|
517
741
|
color: var(--amber);
|
|
518
742
|
}
|
|
743
|
+
/* Task-progress badge ("2/5") parsed from the agent's todo block, sits next
|
|
744
|
+
to the git chip. Pill so it reads as a discrete counter; green when all
|
|
745
|
+
tasks are done. */
|
|
746
|
+
.tasks {
|
|
747
|
+
font-family: var(--mono);
|
|
748
|
+
font-size: 10.5px;
|
|
749
|
+
color: var(--muted);
|
|
750
|
+
white-space: nowrap;
|
|
751
|
+
flex: none;
|
|
752
|
+
padding: 0 5px;
|
|
753
|
+
border: 1px solid var(--line);
|
|
754
|
+
border-radius: 8px;
|
|
755
|
+
}
|
|
756
|
+
.tasks.done {
|
|
757
|
+
color: var(--green);
|
|
758
|
+
border-color: #2a3a2a;
|
|
759
|
+
}
|
|
519
760
|
.detail {
|
|
520
761
|
color: var(--muted);
|
|
521
762
|
font-size: 12.5px;
|
|
@@ -539,6 +780,46 @@
|
|
|
539
780
|
gap: 8px;
|
|
540
781
|
padding: 6px 18px;
|
|
541
782
|
}
|
|
783
|
+
/* Tree branch prefix for nested (sub)agents — monospace so the box-drawing
|
|
784
|
+
glyphs line up into continuous │ ├ └ rails across rows. */
|
|
785
|
+
.tbranch {
|
|
786
|
+
font-family: var(--mono);
|
|
787
|
+
color: var(--muted);
|
|
788
|
+
white-space: pre;
|
|
789
|
+
flex: none;
|
|
790
|
+
opacity: 0.7;
|
|
791
|
+
margin-right: -2px;
|
|
792
|
+
}
|
|
793
|
+
/* Room/peer group header rows (the upper layers of the tree). Non-selectable,
|
|
794
|
+
dimmer and smaller than agent rows so they read as containers, not agents. */
|
|
795
|
+
.ghead {
|
|
796
|
+
display: flex;
|
|
797
|
+
align-items: center;
|
|
798
|
+
gap: 6px;
|
|
799
|
+
padding: 5px 18px 2px;
|
|
800
|
+
font-size: 11.5px;
|
|
801
|
+
color: var(--muted);
|
|
802
|
+
user-select: none;
|
|
803
|
+
}
|
|
804
|
+
.ghead .gicon {
|
|
805
|
+
opacity: 0.6;
|
|
806
|
+
flex: none;
|
|
807
|
+
}
|
|
808
|
+
.ghead .glabel {
|
|
809
|
+
font-weight: 600;
|
|
810
|
+
color: var(--fg);
|
|
811
|
+
overflow: hidden;
|
|
812
|
+
text-overflow: ellipsis;
|
|
813
|
+
white-space: nowrap;
|
|
814
|
+
}
|
|
815
|
+
.ghead.g-room .glabel {
|
|
816
|
+
color: var(--accent);
|
|
817
|
+
}
|
|
818
|
+
.ghead .gkind {
|
|
819
|
+
font-size: 10px;
|
|
820
|
+
opacity: 0.45;
|
|
821
|
+
flex: none;
|
|
822
|
+
}
|
|
542
823
|
.crow .cident {
|
|
543
824
|
font-family: var(--mono);
|
|
544
825
|
font-size: 11.5px;
|
|
@@ -601,9 +882,10 @@
|
|
|
601
882
|
flex-direction: column;
|
|
602
883
|
min-width: 0;
|
|
603
884
|
min-height: 0;
|
|
885
|
+
position: relative; /* anchor for the absolutely-positioned perf HUD */
|
|
604
886
|
}
|
|
605
887
|
.rhead {
|
|
606
|
-
padding: 14px 20px;
|
|
888
|
+
padding: max(14px, var(--sat)) max(20px, var(--sar)) 14px max(20px, var(--sal));
|
|
607
889
|
border-bottom: 1px solid var(--line);
|
|
608
890
|
display: flex;
|
|
609
891
|
align-items: center;
|
|
@@ -611,6 +893,13 @@
|
|
|
611
893
|
}
|
|
612
894
|
.rhead .name {
|
|
613
895
|
font-size: 15px;
|
|
896
|
+
/* shrink + ellipsis so a long agent title can't shove the live/⋯
|
|
897
|
+
controls off the edge on a narrow phone header */
|
|
898
|
+
flex: 0 1 auto;
|
|
899
|
+
min-width: 0;
|
|
900
|
+
overflow: hidden;
|
|
901
|
+
text-overflow: ellipsis;
|
|
902
|
+
white-space: nowrap;
|
|
614
903
|
}
|
|
615
904
|
/* back-to-list affordance — only surfaces in the single-column mobile view */
|
|
616
905
|
.rback {
|
|
@@ -651,7 +940,7 @@
|
|
|
651
940
|
flex: 1;
|
|
652
941
|
min-height: 0;
|
|
653
942
|
overflow: hidden;
|
|
654
|
-
padding: 8px 10px;
|
|
943
|
+
padding: 8px max(10px, var(--sar)) 8px max(10px, var(--sal));
|
|
655
944
|
background: var(--bg);
|
|
656
945
|
}
|
|
657
946
|
.log .xterm {
|
|
@@ -668,6 +957,96 @@
|
|
|
668
957
|
color: var(--muted);
|
|
669
958
|
font-size: 14px;
|
|
670
959
|
}
|
|
960
|
+
/* On-screen key bar — hidden on desktop, flex on mobile (720px block).
|
|
961
|
+
Scrolls horizontally so it never wraps or steals height from the log. */
|
|
962
|
+
.keybar {
|
|
963
|
+
display: none;
|
|
964
|
+
flex: none;
|
|
965
|
+
gap: 6px;
|
|
966
|
+
align-items: center;
|
|
967
|
+
padding: 6px max(8px, var(--sar)) 6px max(8px, var(--sal));
|
|
968
|
+
border-top: 1px solid var(--line);
|
|
969
|
+
background: var(--panel);
|
|
970
|
+
overflow-x: auto;
|
|
971
|
+
-webkit-overflow-scrolling: touch;
|
|
972
|
+
scrollbar-width: none;
|
|
973
|
+
}
|
|
974
|
+
.keybar::-webkit-scrollbar {
|
|
975
|
+
display: none;
|
|
976
|
+
}
|
|
977
|
+
.keybar .kb {
|
|
978
|
+
flex: none;
|
|
979
|
+
min-width: 36px;
|
|
980
|
+
height: 36px;
|
|
981
|
+
padding: 0 11px;
|
|
982
|
+
background: var(--panel2);
|
|
983
|
+
border: 1px solid var(--line);
|
|
984
|
+
border-radius: 8px;
|
|
985
|
+
color: var(--fg);
|
|
986
|
+
font: 13px/1 var(--mono);
|
|
987
|
+
cursor: pointer;
|
|
988
|
+
-webkit-tap-highlight-color: transparent;
|
|
989
|
+
user-select: none;
|
|
990
|
+
}
|
|
991
|
+
.keybar .kb:active {
|
|
992
|
+
background: var(--line);
|
|
993
|
+
}
|
|
994
|
+
/* a sticky Ctrl/Alt that's armed for the next keystroke */
|
|
995
|
+
.keybar .kb.on {
|
|
996
|
+
background: var(--accent);
|
|
997
|
+
border-color: var(--accent);
|
|
998
|
+
color: #fff;
|
|
999
|
+
}
|
|
1000
|
+
/* Line composer — mobile-only. Carries the bottom safe-area inset so it
|
|
1001
|
+
clears the home indicator (completes the terminal pane's inset cover). */
|
|
1002
|
+
.composer {
|
|
1003
|
+
display: none;
|
|
1004
|
+
flex: none;
|
|
1005
|
+
gap: 8px;
|
|
1006
|
+
align-items: flex-end;
|
|
1007
|
+
padding: 8px max(10px, var(--sar)) max(8px, var(--sab)) max(10px, var(--sal));
|
|
1008
|
+
border-top: 1px solid var(--line);
|
|
1009
|
+
background: var(--panel);
|
|
1010
|
+
}
|
|
1011
|
+
.composer textarea {
|
|
1012
|
+
flex: 1;
|
|
1013
|
+
min-width: 0;
|
|
1014
|
+
resize: none;
|
|
1015
|
+
max-height: 120px;
|
|
1016
|
+
padding: 9px 11px;
|
|
1017
|
+
background: var(--bg);
|
|
1018
|
+
border: 1px solid var(--line);
|
|
1019
|
+
border-radius: 10px;
|
|
1020
|
+
color: var(--fg);
|
|
1021
|
+
/* ≥16px so iOS Safari doesn't auto-zoom the page when it gains focus */
|
|
1022
|
+
font:
|
|
1023
|
+
16px/1.4 -apple-system,
|
|
1024
|
+
BlinkMacSystemFont,
|
|
1025
|
+
system-ui,
|
|
1026
|
+
sans-serif;
|
|
1027
|
+
}
|
|
1028
|
+
.composer textarea:focus {
|
|
1029
|
+
outline: none;
|
|
1030
|
+
border-color: var(--accent);
|
|
1031
|
+
}
|
|
1032
|
+
.composer .cmpsend {
|
|
1033
|
+
flex: none;
|
|
1034
|
+
height: 38px;
|
|
1035
|
+
padding: 0 14px;
|
|
1036
|
+
background: var(--accent);
|
|
1037
|
+
border: 1px solid var(--accent);
|
|
1038
|
+
border-radius: 10px;
|
|
1039
|
+
color: #fff;
|
|
1040
|
+
font:
|
|
1041
|
+
600 13px/1 -apple-system,
|
|
1042
|
+
system-ui,
|
|
1043
|
+
sans-serif;
|
|
1044
|
+
cursor: pointer;
|
|
1045
|
+
-webkit-tap-highlight-color: transparent;
|
|
1046
|
+
}
|
|
1047
|
+
.composer .cmpsend:active {
|
|
1048
|
+
opacity: 0.85;
|
|
1049
|
+
}
|
|
671
1050
|
|
|
672
1051
|
/* ---- mobile: collapse the two-pane desktop layout into a single column ----
|
|
673
1052
|
The list and the terminal stack into the same cell; only one is visible at
|
|
@@ -706,7 +1085,7 @@
|
|
|
706
1085
|
}
|
|
707
1086
|
|
|
708
1087
|
.head {
|
|
709
|
-
padding: 12px 14px 8px;
|
|
1088
|
+
padding: max(12px, var(--sat)) max(14px, var(--sar)) 8px max(14px, var(--sal));
|
|
710
1089
|
}
|
|
711
1090
|
h1 {
|
|
712
1091
|
font-size: 17px;
|
|
@@ -730,7 +1109,8 @@
|
|
|
730
1109
|
padding: 10px 14px;
|
|
731
1110
|
}
|
|
732
1111
|
.rhead {
|
|
733
|
-
padding: 12px 14px;
|
|
1112
|
+
padding: max(12px, var(--sat)) max(14px, var(--sar)) 12px max(14px, var(--sal));
|
|
1113
|
+
gap: 8px;
|
|
734
1114
|
}
|
|
735
1115
|
.list .row,
|
|
736
1116
|
.rooms .ritem,
|
|
@@ -741,6 +1121,15 @@
|
|
|
741
1121
|
-webkit-tap-highlight-color: transparent;
|
|
742
1122
|
}
|
|
743
1123
|
}
|
|
1124
|
+
/* Touch input aids (key bar + composer) show on ANY touch device, not just
|
|
1125
|
+
narrow ones — so a tablet in the two-pane layout (typically no hardware
|
|
1126
|
+
keyboard) still gets Esc/Ctrl/arrows and a reliable line composer. */
|
|
1127
|
+
@media (pointer: coarse) {
|
|
1128
|
+
.keybar,
|
|
1129
|
+
.composer {
|
|
1130
|
+
display: flex;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
744
1133
|
</style>
|
|
745
1134
|
<link
|
|
746
1135
|
rel="stylesheet"
|
|
@@ -811,12 +1200,75 @@
|
|
|
811
1200
|
<span class="live"
|
|
812
1201
|
><span class="dot" id="livedot"></span><span id="livetxt">connecting…</span></span
|
|
813
1202
|
>
|
|
1203
|
+
<span
|
|
1204
|
+
id="ctype"
|
|
1205
|
+
class="ctype"
|
|
1206
|
+
title="how your browser reaches this agent's host"
|
|
1207
|
+
hidden
|
|
1208
|
+
></span>
|
|
1209
|
+
<span class="rmenu">
|
|
1210
|
+
<button class="rmenubtn" id="rmenubtn" title="more" aria-label="more">⋯</button>
|
|
1211
|
+
<div class="rmenupanel" id="rmenupanel" hidden>
|
|
1212
|
+
<div class="rmenuitem rmenufont">
|
|
1213
|
+
<span>Font size</span>
|
|
1214
|
+
<span class="fontstep">
|
|
1215
|
+
<button type="button" id="fontDown" aria-label="smaller font">A−</button>
|
|
1216
|
+
<span id="fontVal">12</span>
|
|
1217
|
+
<button type="button" id="fontUp" aria-label="larger font">A+</button>
|
|
1218
|
+
</span>
|
|
1219
|
+
</div>
|
|
1220
|
+
<div class="rmenusep"></div>
|
|
1221
|
+
<label class="rmenuitem"
|
|
1222
|
+
><input type="checkbox" id="perfToggle" /> <span>Perf HUD</span></label
|
|
1223
|
+
>
|
|
1224
|
+
<div class="rmenusep"></div>
|
|
1225
|
+
<button class="rmenuitem rmenuact" id="stopAgent" type="button">⏹ Stop agent</button>
|
|
1226
|
+
<button class="rmenuitem rmenuact danger" id="killAgent" type="button">
|
|
1227
|
+
✕ Force-kill (SIGKILL)
|
|
1228
|
+
</button>
|
|
1229
|
+
</div>
|
|
1230
|
+
</span>
|
|
814
1231
|
</div>
|
|
815
1232
|
<div class="log" id="log">
|
|
816
1233
|
<div class="placeholder">
|
|
817
1234
|
← pick an agent to tail its log; type directly into the terminal
|
|
818
1235
|
</div>
|
|
819
1236
|
</div>
|
|
1237
|
+
<!-- On-screen key bar — phones have no Esc/Tab/Ctrl/arrows, so agent TUIs
|
|
1238
|
+
are otherwise undriveable. Mobile-only (shown in the 720px block);
|
|
1239
|
+
posts raw sequences to the agent over the same /api/send wire. -->
|
|
1240
|
+
<div class="keybar" id="keybar" role="group" aria-label="terminal keys">
|
|
1241
|
+
<button class="kb" data-key="esc" title="Escape">Esc</button>
|
|
1242
|
+
<button class="kb kbmod" data-mod="ctrl" title="Ctrl — applies to the next key">
|
|
1243
|
+
Ctrl
|
|
1244
|
+
</button>
|
|
1245
|
+
<button class="kb kbmod" data-mod="alt" title="Alt — applies to the next key">Alt</button>
|
|
1246
|
+
<button class="kb" data-key="tab" title="Tab">Tab</button>
|
|
1247
|
+
<button class="kb" data-key="cc" title="Ctrl-C — interrupt">^C</button>
|
|
1248
|
+
<button class="kb" data-arrow="up" title="Up" aria-label="Up">↑</button>
|
|
1249
|
+
<button class="kb" data-arrow="down" title="Down" aria-label="Down">↓</button>
|
|
1250
|
+
<button class="kb" data-arrow="left" title="Left" aria-label="Left">←</button>
|
|
1251
|
+
<button class="kb" data-arrow="right" title="Right" aria-label="Right">→</button>
|
|
1252
|
+
<button class="kb" data-key="enter" title="Enter">⏎</button>
|
|
1253
|
+
</div>
|
|
1254
|
+
<!-- Line composer — the reliable path for typing a prompt on a phone:
|
|
1255
|
+
a normal textarea (no IME/autocorrect surprises that plague xterm's
|
|
1256
|
+
hidden input). Enter sends, Shift+Enter inserts a newline. -->
|
|
1257
|
+
<form class="composer" id="composer">
|
|
1258
|
+
<textarea
|
|
1259
|
+
id="cmpin"
|
|
1260
|
+
rows="1"
|
|
1261
|
+
placeholder="Message agent… ↵ to send"
|
|
1262
|
+
enterkeyhint="send"
|
|
1263
|
+
inputmode="text"
|
|
1264
|
+
autocapitalize="off"
|
|
1265
|
+
autocorrect="off"
|
|
1266
|
+
autocomplete="off"
|
|
1267
|
+
spellcheck="false"
|
|
1268
|
+
></textarea>
|
|
1269
|
+
<button type="submit" class="cmpsend" title="Send to agent">Send</button>
|
|
1270
|
+
</form>
|
|
1271
|
+
<div class="perfhud" id="perfhud" hidden></div>
|
|
820
1272
|
</div>
|
|
821
1273
|
</div>
|
|
822
1274
|
|
|
@@ -842,6 +1294,8 @@
|
|
|
842
1294
|
fullIdent,
|
|
843
1295
|
hasIdent,
|
|
844
1296
|
deviceCount,
|
|
1297
|
+
layeredRows,
|
|
1298
|
+
taskLabel,
|
|
845
1299
|
} from "./console-logic.js";
|
|
846
1300
|
import {
|
|
847
1301
|
MARKER as E2E_MARKER,
|
|
@@ -864,6 +1318,28 @@
|
|
|
864
1318
|
let es = null; // live-tail subscription closer
|
|
865
1319
|
let term = null; // xterm.js Terminal rendering the raw PTY stream
|
|
866
1320
|
let fit = null;
|
|
1321
|
+
// Terminal font size (px) — adjustable from the ⋯ menu, persisted across
|
|
1322
|
+
// reloads, applied live to the open terminal and used by every new one.
|
|
1323
|
+
let termFontSize = 12;
|
|
1324
|
+
try {
|
|
1325
|
+
const n = parseInt(localStorage.getItem("ay.fontSize") || "", 10);
|
|
1326
|
+
if (n >= 8 && n <= 32) termFontSize = n;
|
|
1327
|
+
} catch {}
|
|
1328
|
+
function setTermFontSize(px) {
|
|
1329
|
+
termFontSize = Math.max(8, Math.min(32, px | 0));
|
|
1330
|
+
const v = $("fontVal");
|
|
1331
|
+
if (v) v.textContent = String(termFontSize);
|
|
1332
|
+
try {
|
|
1333
|
+
localStorage.setItem("ay.fontSize", String(termFontSize));
|
|
1334
|
+
} catch {}
|
|
1335
|
+
if (term) {
|
|
1336
|
+
term.options.fontSize = termFontSize;
|
|
1337
|
+
if (fit)
|
|
1338
|
+
try {
|
|
1339
|
+
fit.fit();
|
|
1340
|
+
} catch {}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
867
1343
|
|
|
868
1344
|
// xterm paints to a <canvas>, so unlike the CSS var() consumers it can't
|
|
869
1345
|
// ride prefers-color-scheme on its own — its theme is a JS object. Mirror
|
|
@@ -1415,6 +1891,539 @@
|
|
|
1415
1891
|
const srcFor = (e) => (e && sources.get(e._room)) || sources.get(LOCAL) || null;
|
|
1416
1892
|
const txFor = (e) => srcFor(e)?.tx || localTx;
|
|
1417
1893
|
|
|
1894
|
+
// ---- connection-path classification (header [local/lan/wan/relay] pill) ----
|
|
1895
|
+
// Tells the user, at a glance, whether the terminal they're watching rides the
|
|
1896
|
+
// fast direct path or a relayed one — the difference between snappy and laggy.
|
|
1897
|
+
const isLoopbackIp = (ip) => ip === "::1" || /^127\./.test(ip);
|
|
1898
|
+
const isPrivateIp = (ip) =>
|
|
1899
|
+
/^10\./.test(ip) ||
|
|
1900
|
+
/^192\.168\./.test(ip) ||
|
|
1901
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
|
|
1902
|
+
/^169\.254\./.test(ip) || // IPv4 link-local
|
|
1903
|
+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip) || // CGNAT / Tailscale (100.64.0.0/10)
|
|
1904
|
+
/^fe80:/i.test(ip) || // IPv6 link-local
|
|
1905
|
+
/^f[cd][0-9a-f]{2}:/i.test(ip) || // IPv6 ULA (fc00::/7, incl. Tailscale fd7a:…)
|
|
1906
|
+
/\.local$/i.test(ip); // mDNS host candidate (browser-obfuscated LAN IP)
|
|
1907
|
+
// Inspect the selected ICE candidate pair and return the full picture for the
|
|
1908
|
+
// hover tooltip: kind, RTT, both candidates, a NAT-traversal hint, and the open
|
|
1909
|
+
// data channel. `kind` is classified by how we reach the REMOTE host: relay on
|
|
1910
|
+
// either end = TURN-proxied; otherwise the remote candidate's type/address
|
|
1911
|
+
// decides local/lan/wan. (The LOCAL end is often prflx/srflx even on a fast
|
|
1912
|
+
// private path — e.g. a Tailscale/CGNAT mesh shows local=prflx, remote=host
|
|
1913
|
+
// 100.x at ~1ms — so we key on the remote, not on both ends being `host`.)
|
|
1914
|
+
async function inspectConn(pc) {
|
|
1915
|
+
if (!pc || typeof pc.getStats !== "function") return null;
|
|
1916
|
+
try {
|
|
1917
|
+
const stats = await pc.getStats();
|
|
1918
|
+
let pairId = null;
|
|
1919
|
+
stats.forEach((s) => {
|
|
1920
|
+
if (s.type === "transport" && s.selectedCandidatePairId)
|
|
1921
|
+
pairId = s.selectedCandidatePairId;
|
|
1922
|
+
});
|
|
1923
|
+
let pair = null;
|
|
1924
|
+
stats.forEach((s) => {
|
|
1925
|
+
if (
|
|
1926
|
+
pairId
|
|
1927
|
+
? s.id === pairId
|
|
1928
|
+
: s.type === "candidate-pair" && s.state === "succeeded" && s.nominated
|
|
1929
|
+
)
|
|
1930
|
+
pair = s;
|
|
1931
|
+
});
|
|
1932
|
+
if (!pair) return null;
|
|
1933
|
+
let local = null,
|
|
1934
|
+
remote = null;
|
|
1935
|
+
stats.forEach((s) => {
|
|
1936
|
+
if (s.id === pair.localCandidateId) local = s;
|
|
1937
|
+
if (s.id === pair.remoteCandidateId) remote = s;
|
|
1938
|
+
});
|
|
1939
|
+
if (!local || !remote) return null;
|
|
1940
|
+
const lt = local.candidateType,
|
|
1941
|
+
rt = remote.candidateType,
|
|
1942
|
+
rip = remote.address || remote.ip || "";
|
|
1943
|
+
let kind, nat;
|
|
1944
|
+
if (lt === "relay" || rt === "relay") {
|
|
1945
|
+
kind = "relay";
|
|
1946
|
+
nat = "TURN-relayed — symmetric NAT or UDP blocked";
|
|
1947
|
+
} else if (rt === "host") {
|
|
1948
|
+
kind = isLoopbackIp(rip) ? "local" : !rip || isPrivateIp(rip) ? "lan" : "wan";
|
|
1949
|
+
nat =
|
|
1950
|
+
lt === "host"
|
|
1951
|
+
? "direct — both ends on-net"
|
|
1952
|
+
: "direct to host" + (lt === "srflx" || lt === "prflx" ? " (you behind NAT)" : "");
|
|
1953
|
+
} else {
|
|
1954
|
+
kind = "wan";
|
|
1955
|
+
nat = "STUN-reflexive P2P (NAT-traversed)";
|
|
1956
|
+
}
|
|
1957
|
+
const fmt = (c) => c.candidateType + "/" + (c.protocol || "?");
|
|
1958
|
+
return {
|
|
1959
|
+
kind,
|
|
1960
|
+
nat,
|
|
1961
|
+
rttMs:
|
|
1962
|
+
pair.currentRoundTripTime != null
|
|
1963
|
+
? Math.round(pair.currentRoundTripTime * 1000)
|
|
1964
|
+
: null,
|
|
1965
|
+
local:
|
|
1966
|
+
fmt(local) + (local.address || local.ip ? " " + (local.address || local.ip) : ""),
|
|
1967
|
+
remote: fmt(remote) + (rip ? " " + rip : ""),
|
|
1968
|
+
dc:
|
|
1969
|
+
[...stats.values()]
|
|
1970
|
+
.filter((v) => v.type === "data-channel" && v.state === "open")
|
|
1971
|
+
.map((v) => v.label)
|
|
1972
|
+
.join(",") || null,
|
|
1973
|
+
};
|
|
1974
|
+
} catch {
|
|
1975
|
+
return null;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
// Resolve the live RTCPeerConnection that carries a SPECIFIC agent. A codehost
|
|
1979
|
+
// room holds one peer per host (room.rtcs keyed by peerId); byPid maps the
|
|
1980
|
+
// agent's pid to its peerId, so each agent resolves to ITS host's connection —
|
|
1981
|
+
// a VPS agent reads wan while a tak.local agent in the same room reads lan.
|
|
1982
|
+
function pcForAgent(s, e) {
|
|
1983
|
+
if (!s) return null;
|
|
1984
|
+
if (s.kind === "rtc") return s.client?.pc || null; // share room: one host
|
|
1985
|
+
if (s.kind === "ch") {
|
|
1986
|
+
const peerId = s.client?.byPid?.get(String(e?.pid));
|
|
1987
|
+
return (peerId && s.client?.room?.rtcs?.get(peerId)?.pc) || null;
|
|
1988
|
+
}
|
|
1989
|
+
return null; // local ay-serve has no peer connection
|
|
1990
|
+
}
|
|
1991
|
+
// Two cache views, both filled from one classification pass:
|
|
1992
|
+
// - connKinds keyed by the true PEER (room + peerId) drives the header badge,
|
|
1993
|
+
// so two machines that happen to share a hostname never collide.
|
|
1994
|
+
// - hostKinds keyed by (room, host) drives the peer-group header tags, which
|
|
1995
|
+
// only ever render when hosts are already distinct.
|
|
1996
|
+
const hostKeyOf = (e) => (e?._room || "") + " " + (e?._host || "");
|
|
1997
|
+
function peerKeyOf(s, e) {
|
|
1998
|
+
if (!s || s.id === LOCAL || s.kind == null) return LOCAL;
|
|
1999
|
+
if (s.kind === "ch") {
|
|
2000
|
+
const peerId = s.client?.byPid?.get(String(e?.pid));
|
|
2001
|
+
return s.id + " " + (peerId || e?._host || "");
|
|
2002
|
+
}
|
|
2003
|
+
return s.id; // rtc share: one peer per room
|
|
2004
|
+
}
|
|
2005
|
+
const connKinds = new Map(); // peerKey -> "local" | "lan" | "wan" | "relay"
|
|
2006
|
+
const hostKinds = new Map(); // hostKey -> same (peer-header tags)
|
|
2007
|
+
const connInfo = new Map(); // peerKey -> full detail for the hover tooltip
|
|
2008
|
+
let connSig = "";
|
|
2009
|
+
// The signaling server backing a source: codehost rooms use the codehost
|
|
2010
|
+
// signal DO; agent-yes share rooms use their own host; local has none.
|
|
2011
|
+
const signalingOf = (s) =>
|
|
2012
|
+
!s || s.id === LOCAL || s.kind == null
|
|
2013
|
+
? null
|
|
2014
|
+
: s.kind === "ch"
|
|
2015
|
+
? "wss://signal.codehost.dev"
|
|
2016
|
+
: "wss://" + (s.host || SIG_DEFAULT);
|
|
2017
|
+
const CTYPE_COLORS = {
|
|
2018
|
+
local: "var(--green)",
|
|
2019
|
+
lan: "var(--green)",
|
|
2020
|
+
wan: "var(--amber)",
|
|
2021
|
+
relay: "var(--red)",
|
|
2022
|
+
};
|
|
2023
|
+
const CTYPE_TIPS = {
|
|
2024
|
+
local: "this machine (ay serve, local)",
|
|
2025
|
+
lan: "direct over the local network (fast)",
|
|
2026
|
+
wan: "direct over the internet, peer-to-peer (STUN — RTT-bound)",
|
|
2027
|
+
relay: "TURN-relayed — proxied through a server (slowest path)",
|
|
2028
|
+
};
|
|
2029
|
+
// A small colored pill for a connection kind — reused by the header badge and
|
|
2030
|
+
// every peer-group header in the tree.
|
|
2031
|
+
function connTagHtml(kind) {
|
|
2032
|
+
if (!kind) return "";
|
|
2033
|
+
return `<span class="ctag" style="color:${CTYPE_COLORS[kind]}" title="${esc(CTYPE_TIPS[kind])}">${kind}</span>`;
|
|
2034
|
+
}
|
|
2035
|
+
// Classify every distinct peer in the fleet, caching by (room,host). Because
|
|
2036
|
+
// each agent reads its own host's path, a mixed room (VPS + laptop) shows both
|
|
2037
|
+
// wan and lan at once — exactly what tells you a "local" agent is wrongly remote.
|
|
2038
|
+
async function refreshConnKinds() {
|
|
2039
|
+
// Probe each distinct peer once (not once per agent), keyed by peerKey.
|
|
2040
|
+
const reps = new Map(); // peerKey -> a representative entry for that peer
|
|
2041
|
+
for (const e of entries) {
|
|
2042
|
+
const k = peerKeyOf(srcFor(e), e);
|
|
2043
|
+
if (!reps.has(k)) reps.set(k, e);
|
|
2044
|
+
}
|
|
2045
|
+
connKinds.clear();
|
|
2046
|
+
connInfo.clear();
|
|
2047
|
+
await Promise.all(
|
|
2048
|
+
[...reps].map(async ([key, e]) => {
|
|
2049
|
+
const s = srcFor(e);
|
|
2050
|
+
if (!s || s.id === LOCAL || s.kind == null) {
|
|
2051
|
+
if (s && s.live) {
|
|
2052
|
+
connKinds.set(key, "local");
|
|
2053
|
+
connInfo.set(key, {
|
|
2054
|
+
kind: "local",
|
|
2055
|
+
nat: "this machine (ay serve)",
|
|
2056
|
+
signaling: null,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const info = await inspectConn(pcForAgent(s, e));
|
|
2062
|
+
if (info) {
|
|
2063
|
+
connKinds.set(key, info.kind);
|
|
2064
|
+
connInfo.set(key, { ...info, signaling: signalingOf(s) });
|
|
2065
|
+
}
|
|
2066
|
+
}),
|
|
2067
|
+
);
|
|
2068
|
+
// Project peer kinds onto (room,host) keys for the tree's peer headers.
|
|
2069
|
+
hostKinds.clear();
|
|
2070
|
+
for (const e of entries) {
|
|
2071
|
+
const kind = connKinds.get(peerKeyOf(srcFor(e), e));
|
|
2072
|
+
if (kind) hostKinds.set(hostKeyOf(e), kind);
|
|
2073
|
+
}
|
|
2074
|
+
paintHeaderBadge();
|
|
2075
|
+
// Repaint the list only when the peer-header tags actually changed — the poll
|
|
2076
|
+
// already re-renders on data deltas, so this avoids churning the DOM every tick.
|
|
2077
|
+
const sig = [...hostKinds]
|
|
2078
|
+
.sort()
|
|
2079
|
+
.map(([k, v]) => k + "=" + v)
|
|
2080
|
+
.join(";");
|
|
2081
|
+
if (sig !== connSig) {
|
|
2082
|
+
connSig = sig;
|
|
2083
|
+
renderList();
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
// The terminal-header pill shows how this viewer reaches the SELECTED agent's
|
|
2087
|
+
// host peer (local/lan/wan/relay) — per-peer, contextual to what's open. Reads
|
|
2088
|
+
// the cache only (kept sync so renders never await); hidden when no agent is
|
|
2089
|
+
// open (the whole rhead is hidden then anyway).
|
|
2090
|
+
function paintHeaderBadge() {
|
|
2091
|
+
const badge = $("ctype");
|
|
2092
|
+
if (!badge) return;
|
|
2093
|
+
const e = sel ? entries.find((x) => x._key === sel) : null;
|
|
2094
|
+
const key = e ? peerKeyOf(srcFor(e), e) : null;
|
|
2095
|
+
const kind = key ? connKinds.get(key) : null;
|
|
2096
|
+
if (!kind) {
|
|
2097
|
+
badge.hidden = true;
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
badge.hidden = false;
|
|
2101
|
+
badge.style.color = CTYPE_COLORS[kind] || "var(--muted)";
|
|
2102
|
+
badge.removeAttribute("title"); // the hover popover replaces the native title
|
|
2103
|
+
badge.innerHTML = esc(kind) + connTipHtml(kind, connInfo.get(key));
|
|
2104
|
+
}
|
|
2105
|
+
// The on-hover debug popover for the connection pill: latency, both ICE
|
|
2106
|
+
// candidates, NAT-traversal hint, the open data channel, and the signaling
|
|
2107
|
+
// server. Pure detail readout — the same getStats the classifier already ran.
|
|
2108
|
+
function connTipHtml(kind, info) {
|
|
2109
|
+
const rows = [];
|
|
2110
|
+
if (info?.rttMs != null) rows.push(["latency", info.rttMs + " ms RTT"]);
|
|
2111
|
+
if (info?.remote) rows.push(["remote", info.remote]);
|
|
2112
|
+
if (info?.local) rows.push(["local", info.local]);
|
|
2113
|
+
if (info?.nat) rows.push(["nat", info.nat]);
|
|
2114
|
+
if (info?.dc) rows.push(["channel", info.dc]);
|
|
2115
|
+
if (info?.signaling) rows.push(["signal", info.signaling]);
|
|
2116
|
+
const head = `<div class="ctip-h">${esc(CTYPE_TIPS[kind] || kind)}</div>`;
|
|
2117
|
+
const body = rows
|
|
2118
|
+
.map(([k, v]) => `<div><span class="ctip-k">${k}</span>${esc(String(v))}</div>`)
|
|
2119
|
+
.join("");
|
|
2120
|
+
return `<span class="ctip">${head}${body || '<div class="ctip-k">probing…</div>'}</span>`;
|
|
2121
|
+
}
|
|
2122
|
+
// Candidate pairs aren't known the instant a peer connects (ICE keeps probing)
|
|
2123
|
+
// and can change (relay fallback); a light poll keeps every pill honest.
|
|
2124
|
+
setInterval(refreshConnKinds, 2500);
|
|
2125
|
+
|
|
2126
|
+
// ---- live performance HUD (opt-in via the ⋯ menu, hidden by default) -----
|
|
2127
|
+
// Surfaces the metrics that actually move when the console feels laggy: render
|
|
2128
|
+
// fps, keystroke throughput (xterm onData), agent output rate (term.write),
|
|
2129
|
+
// and main-thread jank (long tasks). Counters accumulate over a 1s window;
|
|
2130
|
+
// the I/O hooks live in select() (perfNote on fwd / term.write).
|
|
2131
|
+
const perf = {
|
|
2132
|
+
frames: 0,
|
|
2133
|
+
inEvents: 0,
|
|
2134
|
+
inBytes: 0,
|
|
2135
|
+
outWrites: 0,
|
|
2136
|
+
outBytes: 0,
|
|
2137
|
+
jankMs: 0,
|
|
2138
|
+
echoMs: null,
|
|
2139
|
+
};
|
|
2140
|
+
let perfOn = false;
|
|
2141
|
+
let perfRaf = 0;
|
|
2142
|
+
// Keystroke→reflect latency: stamp the first un-echoed keystroke; the next
|
|
2143
|
+
// term.write that lands is its echo (the PTY round-trip). Accurate while
|
|
2144
|
+
// typing into an otherwise-quiet agent — the real UX number for a TUI.
|
|
2145
|
+
let pendingKeyTs = 0;
|
|
2146
|
+
function perfNote(kind, n) {
|
|
2147
|
+
if (!perfOn) return;
|
|
2148
|
+
if (kind === "in") {
|
|
2149
|
+
perf.inEvents++;
|
|
2150
|
+
perf.inBytes += n || 0;
|
|
2151
|
+
if (!pendingKeyTs) pendingKeyTs = performance.now();
|
|
2152
|
+
} else if (kind === "out") {
|
|
2153
|
+
perf.outWrites++;
|
|
2154
|
+
perf.outBytes += n || 0;
|
|
2155
|
+
if (pendingKeyTs) {
|
|
2156
|
+
perf.echoMs = Math.round(performance.now() - pendingKeyTs);
|
|
2157
|
+
pendingKeyTs = 0;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
function perfReset() {
|
|
2162
|
+
perf.frames =
|
|
2163
|
+
perf.inEvents =
|
|
2164
|
+
perf.inBytes =
|
|
2165
|
+
perf.outWrites =
|
|
2166
|
+
perf.outBytes =
|
|
2167
|
+
perf.jankMs =
|
|
2168
|
+
0;
|
|
2169
|
+
}
|
|
2170
|
+
function perfFrameLoop() {
|
|
2171
|
+
if (!perfOn) {
|
|
2172
|
+
perfRaf = 0;
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
perf.frames++;
|
|
2176
|
+
perfRaf = requestAnimationFrame(perfFrameLoop);
|
|
2177
|
+
}
|
|
2178
|
+
try {
|
|
2179
|
+
// Long tasks fire regardless of the HUD; we only sum them while it's on.
|
|
2180
|
+
new PerformanceObserver((l) => {
|
|
2181
|
+
if (perfOn) for (const e of l.getEntries()) perf.jankMs += e.duration;
|
|
2182
|
+
}).observe({ entryTypes: ["longtask"] });
|
|
2183
|
+
} catch {
|
|
2184
|
+
/* longtask API unavailable (Safari) — jank line just stays 0 */
|
|
2185
|
+
}
|
|
2186
|
+
const fmtBytes = (b) => (b >= 1024 ? (b / 1024).toFixed(1) + " KB" : Math.round(b) + " B");
|
|
2187
|
+
setInterval(() => {
|
|
2188
|
+
const el = $("perfhud");
|
|
2189
|
+
if (!perfOn || !el) {
|
|
2190
|
+
perfReset();
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
const fps = perf.frames; // window is ~1s
|
|
2194
|
+
el.innerHTML =
|
|
2195
|
+
`<b>fps</b> ${fps}\n` +
|
|
2196
|
+
`<b>echo</b> ${perf.echoMs == null ? "—" : perf.echoMs + " ms"}\n` +
|
|
2197
|
+
`<b>keys</b> ${perf.inEvents}/s ${fmtBytes(perf.inBytes)}/s\n` +
|
|
2198
|
+
`<b>out</b> ${perf.outWrites}/s ${fmtBytes(perf.outBytes)}/s\n` +
|
|
2199
|
+
`<b>jank</b> ${Math.round(perf.jankMs)} ms/s`;
|
|
2200
|
+
perfReset(); // note: echoMs is preserved (last keystroke round-trip stays shown)
|
|
2201
|
+
}, 1000);
|
|
2202
|
+
function setPerfHud(on) {
|
|
2203
|
+
perfOn = on;
|
|
2204
|
+
const el = $("perfhud");
|
|
2205
|
+
if (el) el.hidden = !on;
|
|
2206
|
+
const cb = $("perfToggle");
|
|
2207
|
+
if (cb) cb.checked = on;
|
|
2208
|
+
try {
|
|
2209
|
+
localStorage.setItem("ay.perfHud", on ? "1" : "0");
|
|
2210
|
+
} catch {}
|
|
2211
|
+
if (on) {
|
|
2212
|
+
perfReset();
|
|
2213
|
+
if (!perfRaf) perfRaf = requestAnimationFrame(perfFrameLoop);
|
|
2214
|
+
if (el) el.textContent = "measuring…";
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
// Wire the ⋯ overflow menu and restore the saved HUD preference.
|
|
2218
|
+
// Recovery actions for the SELECTED agent, both confirmed first (destructive):
|
|
2219
|
+
// Stop — graceful: send the CLI's exit command (claude/codex /exit, gemini
|
|
2220
|
+
// /quit) + Enter over the existing /api/send stdin wire. (Ctrl+C / Ctrl+C×2
|
|
2221
|
+
// you can already just type — and some agents rely on that pattern — so
|
|
2222
|
+
// we don't reinvent it here.)
|
|
2223
|
+
// Force — escalation a stuck agent can't ignore: a real SIGKILL of its process
|
|
2224
|
+
// GROUP, server-side via POST /api/kill. Use when /exit does nothing.
|
|
2225
|
+
const GRACEFUL_EXIT = { claude: "/exit", codex: "/exit", gemini: "/quit" };
|
|
2226
|
+
async function stopAgent(force) {
|
|
2227
|
+
const e = sel ? entries.find((x) => x._key === sel) : null;
|
|
2228
|
+
if (!e) return;
|
|
2229
|
+
const who = `pid ${e.pid} ${cliLabel(e) || ident(e) || ""}`.trim();
|
|
2230
|
+
const tx = txFor(e),
|
|
2231
|
+
kw = String(e.pid);
|
|
2232
|
+
if (force) {
|
|
2233
|
+
if (!confirm(`Force-kill (SIGKILL) ${who}?\nThis terminates the process immediately.`))
|
|
2234
|
+
return;
|
|
2235
|
+
tx.post("/api/kill", { keyword: kw }).catch(() => {});
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const graceful = GRACEFUL_EXIT[e.cli] || "/exit";
|
|
2239
|
+
if (!confirm(`Stop ${who} — send "${graceful}"?`)) return;
|
|
2240
|
+
const send = (msg) =>
|
|
2241
|
+
tx.post("/api/send", { keyword: kw, msg, code: "none" }).catch(() => {});
|
|
2242
|
+
await send(graceful);
|
|
2243
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2244
|
+
await send("\r");
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// ---- On-screen key bar (mobile) -------------------------------------
|
|
2248
|
+
// A phone keyboard has no Esc/Tab/Ctrl/arrows, so agent TUIs (claude,
|
|
2249
|
+
// codex, vim…) are otherwise undriveable. The bar posts raw byte
|
|
2250
|
+
// sequences straight to the selected agent's stdin, and a sticky Ctrl/Alt
|
|
2251
|
+
// rewrites the NEXT character typed on the soft keyboard.
|
|
2252
|
+
let modCtrl = false,
|
|
2253
|
+
modAlt = false;
|
|
2254
|
+
// Send raw bytes to the currently-selected agent. Uses the live `sel`, so
|
|
2255
|
+
// it always hits the open agent even while an old terminal tears down mid
|
|
2256
|
+
// switch — the same /api/send wire the terminal's own input uses.
|
|
2257
|
+
function sendToSelected(data) {
|
|
2258
|
+
const e = sel ? entries.find((x) => x._key === sel) : null;
|
|
2259
|
+
if (!e || data == null) return;
|
|
2260
|
+
perfNote("in", typeof data === "string" ? data.length : (data?.byteLength ?? 0));
|
|
2261
|
+
txFor(e)
|
|
2262
|
+
.post("/api/send", { keyword: String(e.pid), msg: data, code: "none" })
|
|
2263
|
+
.catch(() => {});
|
|
2264
|
+
}
|
|
2265
|
+
// Apply a pending sticky Ctrl/Alt to ONE typed character (soft-keyboard
|
|
2266
|
+
// path only). Multi-char input — paste, an IME commit, an escape sequence —
|
|
2267
|
+
// passes through untouched. Ctrl folds @A–Z[\]^_ (case-insensitive) to
|
|
2268
|
+
// 0x00–0x1f; Alt/Meta is an ESC prefix. One-shot: clears after one char.
|
|
2269
|
+
function applyStickyMods(d) {
|
|
2270
|
+
if ((!modCtrl && !modAlt) || typeof d !== "string" || d.length !== 1) return d;
|
|
2271
|
+
let c = d;
|
|
2272
|
+
if (modCtrl) {
|
|
2273
|
+
const u = d.toUpperCase().charCodeAt(0);
|
|
2274
|
+
if (u >= 64 && u <= 95) c = String.fromCharCode(u - 64);
|
|
2275
|
+
else if (u === 32) c = "\x00"; // Ctrl-Space → NUL
|
|
2276
|
+
}
|
|
2277
|
+
if (modAlt) c = "\x1b" + c;
|
|
2278
|
+
modCtrl = modAlt = false;
|
|
2279
|
+
syncModButtons();
|
|
2280
|
+
return c;
|
|
2281
|
+
}
|
|
2282
|
+
function syncModButtons() {
|
|
2283
|
+
const kb = $("keybar");
|
|
2284
|
+
if (!kb) return;
|
|
2285
|
+
kb.querySelector('[data-mod="ctrl"]')?.classList.toggle("on", modCtrl);
|
|
2286
|
+
kb.querySelector('[data-mod="alt"]')?.classList.toggle("on", modAlt);
|
|
2287
|
+
}
|
|
2288
|
+
(function keybarBoot() {
|
|
2289
|
+
const kb = $("keybar");
|
|
2290
|
+
if (!kb) return;
|
|
2291
|
+
const SEQ = { esc: "\x1b", tab: "\t", enter: "\r", cc: "\x03" };
|
|
2292
|
+
const ARROW = { up: "A", down: "B", right: "C", left: "D" };
|
|
2293
|
+
// Don't let a button steal focus — that would drop the soft keyboard and
|
|
2294
|
+
// blur whatever the user is typing into (terminal or composer).
|
|
2295
|
+
kb.addEventListener("pointerdown", (ev) => {
|
|
2296
|
+
if (ev.target.closest("button")) ev.preventDefault();
|
|
2297
|
+
});
|
|
2298
|
+
kb.addEventListener("click", (ev) => {
|
|
2299
|
+
const b = ev.target.closest("button.kb");
|
|
2300
|
+
if (!b) return;
|
|
2301
|
+
if (b.dataset.mod) {
|
|
2302
|
+
// sticky toggle — armed for the next soft-keyboard character
|
|
2303
|
+
if (b.dataset.mod === "ctrl") modCtrl = !modCtrl;
|
|
2304
|
+
else modAlt = !modAlt;
|
|
2305
|
+
syncModButtons();
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
let seq = null;
|
|
2309
|
+
if (b.dataset.arrow) {
|
|
2310
|
+
const L = ARROW[b.dataset.arrow];
|
|
2311
|
+
if (modCtrl || modAlt) {
|
|
2312
|
+
// Ctrl/Alt + arrow → CSI modifier form ESC [ 1 ; <m> <L>, where
|
|
2313
|
+
// m = 1 + Alt(2) + Ctrl(4) (e.g. Ctrl-Left = ESC [ 1 ; 5 D for
|
|
2314
|
+
// word-wise motion). Modified cursor keys are always CSI, never
|
|
2315
|
+
// SS3, so DECCKM doesn't apply when a modifier is held.
|
|
2316
|
+
seq = "\x1b[1;" + (1 + (modAlt ? 2 : 0) + (modCtrl ? 4 : 0)) + L;
|
|
2317
|
+
} else {
|
|
2318
|
+
// Honour application-cursor-key mode (DECCKM): TUIs that enable it
|
|
2319
|
+
// expect SS3 (ESC O x) arrows, not CSI (ESC [ x). xterm tracks it.
|
|
2320
|
+
const app = term && term.modes && term.modes.applicationCursorKeysMode;
|
|
2321
|
+
seq = (app ? "\x1bO" : "\x1b[") + L;
|
|
2322
|
+
}
|
|
2323
|
+
} else if (b.dataset.key) {
|
|
2324
|
+
seq = SEQ[b.dataset.key];
|
|
2325
|
+
// Alt/Meta on a fixed key = ESC prefix (e.g. Alt+Enter). Ctrl on
|
|
2326
|
+
// Esc/Tab/Enter has no standard sequence, so it's left as the bare key.
|
|
2327
|
+
if (seq != null && modAlt) seq = "\x1b" + seq;
|
|
2328
|
+
}
|
|
2329
|
+
if (seq == null) return;
|
|
2330
|
+
// the armed modifier (if any) has now been folded into this key — clear it
|
|
2331
|
+
modCtrl = modAlt = false;
|
|
2332
|
+
syncModButtons();
|
|
2333
|
+
sendToSelected(seq);
|
|
2334
|
+
});
|
|
2335
|
+
})();
|
|
2336
|
+
// ---- Line composer (mobile) ----------------------------------------
|
|
2337
|
+
// The dependable way to type a prompt on a phone: a real textarea, immune
|
|
2338
|
+
// to the IME/autocorrect glitches that dog xterm's hidden input. Enter
|
|
2339
|
+
// sends text + a carriage return in one write (identical to typing then ↵);
|
|
2340
|
+
// Shift+Enter inserts a literal newline.
|
|
2341
|
+
(function composerBoot() {
|
|
2342
|
+
const form = $("composer"),
|
|
2343
|
+
ta = $("cmpin");
|
|
2344
|
+
if (!form || !ta) return;
|
|
2345
|
+
const grow = () => {
|
|
2346
|
+
ta.style.height = "auto";
|
|
2347
|
+
ta.style.height = Math.min(ta.scrollHeight, 120) + "px";
|
|
2348
|
+
};
|
|
2349
|
+
const sendLine = () => {
|
|
2350
|
+
let v = ta.value;
|
|
2351
|
+
if (!v) return;
|
|
2352
|
+
// An armed sticky Ctrl/Alt decorates a single-char line (e.g. Ctrl-D);
|
|
2353
|
+
// longer text can't carry it, but the modifier must still be cleared so
|
|
2354
|
+
// it doesn't silently leak into the next terminal keystroke.
|
|
2355
|
+
v = applyStickyMods(v);
|
|
2356
|
+
if (modCtrl || modAlt) {
|
|
2357
|
+
modCtrl = modAlt = false;
|
|
2358
|
+
syncModButtons();
|
|
2359
|
+
}
|
|
2360
|
+
sendToSelected(v + "\r");
|
|
2361
|
+
ta.value = "";
|
|
2362
|
+
grow();
|
|
2363
|
+
ta.focus(); // keep the soft keyboard up for the next line
|
|
2364
|
+
};
|
|
2365
|
+
ta.addEventListener("input", grow);
|
|
2366
|
+
form.addEventListener("submit", (ev) => {
|
|
2367
|
+
ev.preventDefault();
|
|
2368
|
+
sendLine();
|
|
2369
|
+
});
|
|
2370
|
+
// Enter sends; Shift+Enter inserts a newline; never fire mid-IME-commit
|
|
2371
|
+
// (the confirming Enter reports keyCode 229 / isComposing). `enterSeen`
|
|
2372
|
+
// lets the beforeinput fallback below know keydown already decided this
|
|
2373
|
+
// Enter (so it doesn't double-send, and Shift+Enter still makes a newline).
|
|
2374
|
+
let enterSeen = false;
|
|
2375
|
+
ta.addEventListener("keydown", (ev) => {
|
|
2376
|
+
enterSeen = false;
|
|
2377
|
+
if (ev.key === "Enter" && !ev.isComposing && ev.keyCode !== 229) {
|
|
2378
|
+
enterSeen = true;
|
|
2379
|
+
if (!ev.shiftKey) {
|
|
2380
|
+
ev.preventDefault();
|
|
2381
|
+
sendLine();
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
// Android (Gboard) commonly delivers Enter as keyCode 229 / key
|
|
2386
|
+
// "Unidentified", so the keydown above never matches it. beforeinput's
|
|
2387
|
+
// insertLineBreak fires reliably there; skip it when keydown already
|
|
2388
|
+
// saw the Enter (desktop/iOS, including the Shift+Enter newline case).
|
|
2389
|
+
ta.addEventListener("beforeinput", (ev) => {
|
|
2390
|
+
if (ev.inputType !== "insertLineBreak") return;
|
|
2391
|
+
if (enterSeen) {
|
|
2392
|
+
enterSeen = false;
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
ev.preventDefault();
|
|
2396
|
+
sendLine();
|
|
2397
|
+
});
|
|
2398
|
+
})();
|
|
2399
|
+
(function perfMenuBoot() {
|
|
2400
|
+
const btn = $("rmenubtn"),
|
|
2401
|
+
panel = $("rmenupanel"),
|
|
2402
|
+
cb = $("perfToggle");
|
|
2403
|
+
const closePanel = () => panel && (panel.hidden = true);
|
|
2404
|
+
if (btn && panel) {
|
|
2405
|
+
btn.addEventListener("click", (ev) => {
|
|
2406
|
+
ev.stopPropagation();
|
|
2407
|
+
panel.hidden = !panel.hidden;
|
|
2408
|
+
});
|
|
2409
|
+
document.addEventListener("click", (ev) => {
|
|
2410
|
+
if (!panel.hidden && !panel.contains(ev.target) && ev.target !== btn) closePanel();
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
if (cb) cb.addEventListener("change", () => setPerfHud(cb.checked));
|
|
2414
|
+
$("stopAgent")?.addEventListener("click", () => (closePanel(), stopAgent(false)));
|
|
2415
|
+
$("killAgent")?.addEventListener("click", () => (closePanel(), stopAgent(true)));
|
|
2416
|
+
// Font stepper — stays open so you can tap repeatedly; persists + refits.
|
|
2417
|
+
$("fontDown")?.addEventListener("click", () => setTermFontSize(termFontSize - 1));
|
|
2418
|
+
$("fontUp")?.addEventListener("click", () => setTermFontSize(termFontSize + 1));
|
|
2419
|
+
setTermFontSize(termFontSize); // sync the label with the restored value
|
|
2420
|
+
let saved = false;
|
|
2421
|
+
try {
|
|
2422
|
+
saved = localStorage.getItem("ay.perfHud") === "1";
|
|
2423
|
+
} catch {}
|
|
2424
|
+
setPerfHud(saved);
|
|
2425
|
+
})();
|
|
2426
|
+
|
|
1418
2427
|
// The local source is only worth polling when this page is actually backed
|
|
1419
2428
|
// by an ay serve: localhost, or served by `ay serve --http` (which leaves a
|
|
1420
2429
|
// token), or when there are no rooms to fall back on. On the public origin
|
|
@@ -1545,28 +2554,62 @@
|
|
|
1545
2554
|
return `<span class="git${g.dirty ? " dirty" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
|
|
1546
2555
|
}
|
|
1547
2556
|
|
|
2557
|
+
// Task-progress badge ("2/5") parsed from the agent's todo block, shown next
|
|
2558
|
+
// to the git chip. Amber-ish when all done; omitted entirely when no block.
|
|
2559
|
+
function taskChipHtml(e) {
|
|
2560
|
+
const label = taskLabel(e);
|
|
2561
|
+
if (!label) return "";
|
|
2562
|
+
const t = e.tasks || {};
|
|
2563
|
+
const allDone = t.total > 0 && t.done >= t.total;
|
|
2564
|
+
const tip = `tasks: ${t.done || 0} done / ${t.total || 0} total`;
|
|
2565
|
+
return `<span class="tasks${allDone ? " done" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// A room/peer group header row. Non-selectable; carries the same tree rails
|
|
2569
|
+
// as agent rows so the hierarchy reads as one tree. The room/peer layers
|
|
2570
|
+
// only appear here when there are ≥2 of them (single ones fold away in
|
|
2571
|
+
// layeredRows), so a header always disambiguates something real.
|
|
2572
|
+
function headerHtml(r) {
|
|
2573
|
+
const icon = r.kind === "room" ? "▤" : "▣";
|
|
2574
|
+
// Peer headers carry their host's connection type (lan/wan/relay), so a
|
|
2575
|
+
// mixed room shows at a glance which hosts are local vs reached over WAN.
|
|
2576
|
+
const ctag =
|
|
2577
|
+
r.kind === "peer"
|
|
2578
|
+
? connTagHtml(hostKinds.get((r.room || "") + " " + (r.host || "")))
|
|
2579
|
+
: "";
|
|
2580
|
+
return `<div class="ghead g-${r.kind}">${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}<span class="gicon">${icon}</span><span class="glabel">${esc(r.label || "")}</span>${ctag}<span class="gkind">${r.kind}</span></div>`;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
1548
2583
|
function renderList() {
|
|
1549
2584
|
const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
|
|
1550
|
-
|
|
1551
|
-
|
|
2585
|
+
// Build the full signalling-server > rooms > peers > agents > subagents
|
|
2586
|
+
// tree. Single-node room/peer layers fold away; ≥2 become ├ └ │ branches.
|
|
2587
|
+
const rows = layeredRows(entries.filter((e) => matches(e, toks)));
|
|
2588
|
+
const agentRows = rows.filter((r) => r.kind === "agent");
|
|
2589
|
+
$("count").textContent = `${agentRows.length} / ${entries.length} agents`;
|
|
1552
2590
|
$("viewbtn").classList.toggle("on", compactList);
|
|
1553
2591
|
// identContext blanks any field uniform across the shown list (so a
|
|
1554
2592
|
// single-device fleet shows no device); multiDevice gates the detailed
|
|
1555
2593
|
// host tag so it only appears when machines are actually mixed.
|
|
1556
|
-
const
|
|
1557
|
-
const
|
|
2594
|
+
const shownEntries = agentRows.map((r) => r.entry);
|
|
2595
|
+
const ctx = identContext(shownEntries);
|
|
2596
|
+
const multiDevice = deviceCount(shownEntries) > 1;
|
|
1558
2597
|
if (compactList) {
|
|
1559
2598
|
$("list").innerHTML =
|
|
1560
|
-
|
|
1561
|
-
.map((
|
|
2599
|
+
rows
|
|
2600
|
+
.map((r) => {
|
|
2601
|
+
if (r.kind !== "agent") return headerHtml(r);
|
|
2602
|
+
const e = r.entry;
|
|
1562
2603
|
const t = e.title || e.prompt || "";
|
|
1563
|
-
const id = compactIdent(e, ctx);
|
|
2604
|
+
const id = compactIdent(e, ctx, 3, r.parentEntry);
|
|
1564
2605
|
const cli = cliLabel(e);
|
|
1565
2606
|
return `<div class="row crow ${e._key === sel ? "sel" : ""}" data-key="${esc(e._key)}">
|
|
2607
|
+
${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}
|
|
1566
2608
|
<span class="dot ${esc(e.status)}"></span>
|
|
1567
2609
|
${hasIdent(id) ? `<span class="cident" title="${esc(fullIdent(e))}">${esc(id)}</span>` : ""}
|
|
1568
2610
|
${cli ? `<span class="cname">${esc(cli)}</span>` : ""}
|
|
1569
2611
|
<span class="ctitle ${e.title ? "" : "dim"}" title="${esc(t)}">${esc(t)}</span>
|
|
2612
|
+
${taskChipHtml(e)}
|
|
1570
2613
|
${gitChipHtml(e)}
|
|
1571
2614
|
<span class="age">${age(e)}</span></div>`;
|
|
1572
2615
|
})
|
|
@@ -1574,10 +2617,12 @@
|
|
|
1574
2617
|
return;
|
|
1575
2618
|
}
|
|
1576
2619
|
$("list").innerHTML =
|
|
1577
|
-
|
|
1578
|
-
.map((
|
|
2620
|
+
rows
|
|
2621
|
+
.map((r) => {
|
|
2622
|
+
if (r.kind !== "agent") return headerHtml(r);
|
|
2623
|
+
const e = r.entry;
|
|
1579
2624
|
// The host tag only earns its place when several machines are in
|
|
1580
|
-
// play
|
|
2625
|
+
// play AND it isn't already shown by a peer header above this row.
|
|
1581
2626
|
const tags = tagsFor(e)
|
|
1582
2627
|
.filter(([k]) => k !== "host" || multiDevice)
|
|
1583
2628
|
.map(
|
|
@@ -1586,9 +2631,10 @@
|
|
|
1586
2631
|
)
|
|
1587
2632
|
.join("");
|
|
1588
2633
|
return `<div class="row ${e._key === sel ? "sel" : ""}" data-key="${esc(e._key)}">
|
|
1589
|
-
<div class="r1"
|
|
2634
|
+
<div class="r1">${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}<span class="dot ${esc(e.status)}"></span>
|
|
1590
2635
|
<span class="name">${esc(cliLabel(e) || ident(e) || "agent")}</span>
|
|
1591
2636
|
<span class="badge">pid ${e.pid}</span>
|
|
2637
|
+
${taskChipHtml(e)}
|
|
1592
2638
|
${gitChipHtml(e)}
|
|
1593
2639
|
<span class="age">${age(e)}</span></div>
|
|
1594
2640
|
${e.title ? `<div class="rowtitle" title="${esc(e.title)}">${esc(e.title)}</div>` : ""}
|
|
@@ -1690,6 +2736,7 @@
|
|
|
1690
2736
|
return;
|
|
1691
2737
|
}
|
|
1692
2738
|
sel = e._key;
|
|
2739
|
+
paintHeaderBadge();
|
|
1693
2740
|
// Remember the selection so a refresh re-opens this agent (see boot/autoPid).
|
|
1694
2741
|
try {
|
|
1695
2742
|
localStorage.setItem("ay.sel", sel);
|
|
@@ -1730,7 +2777,7 @@
|
|
|
1730
2777
|
disableStdin: false,
|
|
1731
2778
|
cursorBlink: true,
|
|
1732
2779
|
scrollback: 5000,
|
|
1733
|
-
fontSize:
|
|
2780
|
+
fontSize: termFontSize,
|
|
1734
2781
|
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
|
1735
2782
|
theme: termTheme(),
|
|
1736
2783
|
});
|
|
@@ -1796,8 +2843,11 @@
|
|
|
1796
2843
|
// keyword with 400 (pid arrives as a number from /api/ls JSON).
|
|
1797
2844
|
const kw = String(pid);
|
|
1798
2845
|
const fwd = (d) => {
|
|
1799
|
-
if (sel
|
|
1800
|
-
|
|
2846
|
+
if (sel !== e._key) return; // ignore a stale terminal mid-switch
|
|
2847
|
+
// a soft-keyboard character may be decorated by an armed sticky Ctrl/Alt
|
|
2848
|
+
if (typeof d === "string") d = applyStickyMods(d);
|
|
2849
|
+
perfNote("in", typeof d === "string" ? d.length : (d?.byteLength ?? 0));
|
|
2850
|
+
tx.post("/api/send", { keyword: kw, msg: d, code: "none" }).catch(() => {});
|
|
1801
2851
|
};
|
|
1802
2852
|
term.onData(fwd);
|
|
1803
2853
|
term.onBinary(fwd);
|
|
@@ -1837,7 +2887,10 @@
|
|
|
1837
2887
|
const close = tx.subscribe(
|
|
1838
2888
|
"/api/tail/" + encodeURIComponent(pid) + "?raw=1",
|
|
1839
2889
|
(raw) => {
|
|
1840
|
-
if (term)
|
|
2890
|
+
if (term) {
|
|
2891
|
+
term.write(raw);
|
|
2892
|
+
perfNote("out", raw?.length ?? 0);
|
|
2893
|
+
}
|
|
1841
2894
|
},
|
|
1842
2895
|
() => {
|
|
1843
2896
|
$("livedot").className = "dot active";
|
|
@@ -1855,11 +2908,51 @@
|
|
|
1855
2908
|
const row = ev.target.closest(".row");
|
|
1856
2909
|
if (row) select(row.dataset.key);
|
|
1857
2910
|
});
|
|
1858
|
-
// Mobile back
|
|
2911
|
+
// Mobile back: return to the list pane. The tail keeps streaming in the
|
|
1859
2912
|
// background (selection unchanged), so reopening the agent is instant.
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
2913
|
+
const goBack = () => document.querySelector(".app").classList.remove("show-detail");
|
|
2914
|
+
$("rback").addEventListener("click", goBack);
|
|
2915
|
+
// Swipe-right-from-the-left-edge → back, an alternative to the ‹ button.
|
|
2916
|
+
// Passive/read-only so it never fights xterm's own scroll or text
|
|
2917
|
+
// selection; armed only from the left edge, in the single-column view, and
|
|
2918
|
+
// never when a TUI has mouse tracking on (that touch belongs to the agent).
|
|
2919
|
+
(function swipeBackBoot() {
|
|
2920
|
+
const pane = document.querySelector(".right");
|
|
2921
|
+
if (!pane) return;
|
|
2922
|
+
let sx = 0,
|
|
2923
|
+
sy = 0,
|
|
2924
|
+
st = 0,
|
|
2925
|
+
armed = false;
|
|
2926
|
+
pane.addEventListener(
|
|
2927
|
+
"touchstart",
|
|
2928
|
+
(ev) => {
|
|
2929
|
+
armed = false;
|
|
2930
|
+
if (window.innerWidth > 720 || ev.touches.length !== 1) return;
|
|
2931
|
+
const t = ev.touches[0];
|
|
2932
|
+
armed = t.clientX <= 28; // left-edge start only
|
|
2933
|
+
sx = t.clientX;
|
|
2934
|
+
sy = t.clientY;
|
|
2935
|
+
st = ev.timeStamp;
|
|
2936
|
+
},
|
|
2937
|
+
{ passive: true },
|
|
2938
|
+
);
|
|
2939
|
+
pane.addEventListener(
|
|
2940
|
+
"touchend",
|
|
2941
|
+
(ev) => {
|
|
2942
|
+
if (!armed) return;
|
|
2943
|
+
armed = false;
|
|
2944
|
+
const mt = term && term.modes && term.modes.mouseTrackingMode;
|
|
2945
|
+
if (mt && mt !== "none") return; // the TUI wants this touch
|
|
2946
|
+
const t = ev.changedTouches[0];
|
|
2947
|
+
if (!t) return;
|
|
2948
|
+
const dx = t.clientX - sx,
|
|
2949
|
+
dy = t.clientY - sy;
|
|
2950
|
+
// a quick, clearly-horizontal rightward flick (not a scroll/tap)
|
|
2951
|
+
if (dx > 45 && Math.abs(dx) > 2 * Math.abs(dy) && ev.timeStamp - st < 500) goBack();
|
|
2952
|
+
},
|
|
2953
|
+
{ passive: true },
|
|
2954
|
+
);
|
|
2955
|
+
})();
|
|
1863
2956
|
$("q").addEventListener("input", () => {
|
|
1864
2957
|
try {
|
|
1865
2958
|
localStorage.setItem("ay.filter", $("q").value);
|
|
@@ -1877,12 +2970,35 @@
|
|
|
1877
2970
|
fit.fit();
|
|
1878
2971
|
} catch {}
|
|
1879
2972
|
});
|
|
2973
|
+
// iOS overlays the soft keyboard ON TOP of the layout viewport rather than
|
|
2974
|
+
// shrinking it, so a bottom-anchored composer would hide behind it. Track
|
|
2975
|
+
// the visual viewport and reserve the covered height as --kb (which .app
|
|
2976
|
+
// subtracts from its height), then refit the terminal. Android Chrome
|
|
2977
|
+
// resizes the layout viewport itself (interactive-widget=resizes-content),
|
|
2978
|
+
// so --kb stays ~0 there — this is harmless and idempotent on desktop too.
|
|
2979
|
+
if (window.visualViewport) {
|
|
2980
|
+
const vv = window.visualViewport;
|
|
2981
|
+
const onVV = () => {
|
|
2982
|
+
const kb = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
|
|
2983
|
+
document.documentElement.style.setProperty("--kb", kb + "px");
|
|
2984
|
+
if (fit)
|
|
2985
|
+
try {
|
|
2986
|
+
fit.fit();
|
|
2987
|
+
} catch {}
|
|
2988
|
+
};
|
|
2989
|
+
vv.addEventListener("resize", onVV);
|
|
2990
|
+
vv.addEventListener("scroll", onVV);
|
|
2991
|
+
onVV();
|
|
2992
|
+
}
|
|
1880
2993
|
|
|
1881
2994
|
// Step the selection up/down the (filtered) list — same order the left panel
|
|
1882
2995
|
// renders. Clamps at the ends, scrolls the row into view.
|
|
1883
2996
|
function stepSelection(dir) {
|
|
1884
2997
|
const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
|
|
1885
|
-
|
|
2998
|
+
// Must match renderList's order; step only over agent rows (skip headers).
|
|
2999
|
+
const shown = layeredRows(entries.filter((e) => matches(e, toks)))
|
|
3000
|
+
.filter((r) => r.kind === "agent")
|
|
3001
|
+
.map((r) => r.entry);
|
|
1886
3002
|
if (!shown.length) return;
|
|
1887
3003
|
const cur = shown.findIndex((e) => e._key === sel);
|
|
1888
3004
|
const next = shown[nextIndex(shown.length, cur, dir)];
|
|
@@ -2483,7 +3599,15 @@
|
|
|
2483
3599
|
// composite key so it picks the right host when pids collide across rooms.
|
|
2484
3600
|
const [, room, aid] = full;
|
|
2485
3601
|
autoPid = room + "#" + aid;
|
|
2486
|
-
|
|
3602
|
+
// A #room:agentId hash is BOTH our own persisted selection (select()
|
|
3603
|
+
// writes it so a refresh reopens) AND a shareable deep link — same
|
|
3604
|
+
// format, two intents. Disambiguate by navigation type: a reload is a
|
|
3605
|
+
// restore (on a phone, just re-highlight the row and stay on the list,
|
|
3606
|
+
// per mergeRender), while a fresh navigation is a deliberate "take me
|
|
3607
|
+
// to this agent" that opens even on mobile. (No nav entry → treat as
|
|
3608
|
+
// explicit, preserving the old always-open behaviour.)
|
|
3609
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
3610
|
+
autoPidExplicit = nav ? nav.type !== "reload" : true;
|
|
2487
3611
|
const r = loadRooms()[room];
|
|
2488
3612
|
if (r) pending = { room, token: r.token, host: r.host };
|
|
2489
3613
|
} else if (bare && loadRooms()[bare[1]]) {
|