@oddessentials/agent-guild 0.28.0 → 0.28.1
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/README.md +6 -0
- package/package.json +1 -1
- package/web/app.js +63 -7
- package/web/index.html +8 -0
- package/web/styles.css +29 -4
- package/web/terminal-controls.js +130 -0
package/README.md
CHANGED
|
@@ -68,6 +68,12 @@ session name in the header switches the focused terminal to another session.
|
|
|
68
68
|
On a phone one terminal shows at a time, with a button to switch to the
|
|
69
69
|
other. This browser reopens the terminals you had open after a reload.
|
|
70
70
|
|
|
71
|
+
On phones and tablets, a strip below the terminal provides **← ↑ ↓ → Enter
|
|
72
|
+
Esc**. Tap once to move through a prompt, confirm a choice or send Escape.
|
|
73
|
+
The keys act on the focused terminal and leave the on-screen keyboard as
|
|
74
|
+
it is. They are disabled while the terminal reconnects or after it exits;
|
|
75
|
+
taps made while disconnected are never replayed.
|
|
76
|
+
|
|
71
77
|
On a touchscreen, **Copy…** opens a still copy of the terminal text. Touch and
|
|
72
78
|
hold to select, then use **Copy selection** or the browser's Copy menu. **Done**
|
|
73
79
|
returns to the live terminal; the session keeps running while you copy. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/web/app.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
|
|
5
5
|
import { TerminalCopy } from './terminal-copy.js';
|
|
6
|
+
import { TerminalControls, bindTerminalViewport } from './terminal-controls.js';
|
|
6
7
|
import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
|
|
7
8
|
import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
|
|
8
9
|
import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
|
|
@@ -124,6 +125,7 @@ function setConnection(kind, label) {
|
|
|
124
125
|
el.title = label;
|
|
125
126
|
// The manager can only be stopped, restarted or upgraded while the page can reach it.
|
|
126
127
|
state.connected = kind === 'ok';
|
|
128
|
+
terminalControls.refresh();
|
|
127
129
|
state.remoteAccessUI?.connectionChanged();
|
|
128
130
|
$('manager').hidden = !state.connected;
|
|
129
131
|
if (!state.connected) closeMenu($('manager-menu'));
|
|
@@ -4426,11 +4428,18 @@ class TerminalView {
|
|
|
4426
4428
|
|
|
4427
4429
|
connect() {
|
|
4428
4430
|
if (this.disposed || state.remoteRevoked) return;
|
|
4431
|
+
this.inputReady = false;
|
|
4432
|
+
this.inputSnapshot = null;
|
|
4433
|
+
terminalControls.refresh();
|
|
4429
4434
|
const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
|
|
4430
4435
|
this.ws = ws;
|
|
4431
4436
|
ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
|
|
4432
|
-
ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
|
|
4437
|
+
ws.onmessage = (event) => { if (!this.disposed && this.ws === ws) this.onMessage(JSON.parse(event.data)); };
|
|
4433
4438
|
ws.onclose = (event) => {
|
|
4439
|
+
if (this.ws !== ws) return;
|
|
4440
|
+
this.inputReady = false;
|
|
4441
|
+
this.inputSnapshot = null;
|
|
4442
|
+
terminalControls.refresh();
|
|
4434
4443
|
if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
|
|
4435
4444
|
const delay = Math.min(5000, 300 * 2 ** this.retry++);
|
|
4436
4445
|
setTimeout(() => this.connect(), delay);
|
|
@@ -4440,14 +4449,31 @@ class TerminalView {
|
|
|
4440
4449
|
onMessage(msg) {
|
|
4441
4450
|
switch (msg.type) {
|
|
4442
4451
|
case 'snapshot':
|
|
4452
|
+
this.inputReady = false;
|
|
4453
|
+
this.inputSnapshot = msg;
|
|
4454
|
+
this.inputRun = msg.session.startedAt;
|
|
4455
|
+
terminalControls.refresh();
|
|
4443
4456
|
this.term.reset();
|
|
4444
4457
|
this.term.resize(msg.cols, msg.rows);
|
|
4445
|
-
|
|
4458
|
+
{
|
|
4459
|
+
const ws = this.ws;
|
|
4460
|
+
this.term.write(msg.data, () => {
|
|
4461
|
+
if (this.disposed || this.ws !== ws || ws.readyState !== WebSocket.OPEN || this.inputSnapshot !== msg) return;
|
|
4462
|
+
this.inputReady = msg.session.status === 'running';
|
|
4463
|
+
this.inputSnapshot = null;
|
|
4464
|
+
this.sent = { cols: 0, rows: 0 };
|
|
4465
|
+
this.refit();
|
|
4466
|
+
terminalControls.refresh();
|
|
4467
|
+
});
|
|
4468
|
+
}
|
|
4446
4469
|
break;
|
|
4447
4470
|
case 'data':
|
|
4448
4471
|
this.term.write(msg.data);
|
|
4449
4472
|
break;
|
|
4450
4473
|
case 'exit':
|
|
4474
|
+
this.inputReady = false;
|
|
4475
|
+
this.inputSnapshot = null;
|
|
4476
|
+
terminalControls.refresh();
|
|
4451
4477
|
if (dictation?.id === this.id) stopDictation();
|
|
4452
4478
|
this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
|
|
4453
4479
|
break;
|
|
@@ -4532,6 +4558,9 @@ class TerminalView {
|
|
|
4532
4558
|
|
|
4533
4559
|
dispose() {
|
|
4534
4560
|
this.disposed = true;
|
|
4561
|
+
this.inputReady = false;
|
|
4562
|
+
this.inputSnapshot = null;
|
|
4563
|
+
terminalControls.refresh();
|
|
4535
4564
|
this.resizeObserver.disconnect();
|
|
4536
4565
|
this.ws?.close();
|
|
4537
4566
|
this.term.dispose();
|
|
@@ -4541,6 +4570,20 @@ class TerminalView {
|
|
|
4541
4570
|
|
|
4542
4571
|
// ---- terminal panel -------------------------------------------------------
|
|
4543
4572
|
|
|
4573
|
+
const terminalControls = new TerminalControls({
|
|
4574
|
+
element: $('terminal-controls'), panel: $('terminal-panel'),
|
|
4575
|
+
getCurrent: () => {
|
|
4576
|
+
const session = state.sessions.get(state.activeId);
|
|
4577
|
+
const view = state.views.get(state.activeId);
|
|
4578
|
+
if (!session || session.status !== 'running' || !view?.inputReady || view.disposed
|
|
4579
|
+
|| session.startedAt !== view.inputRun || view.ws?.readyState !== WebSocket.OPEN
|
|
4580
|
+
|| !state.connected || state.pageAway || state.remoteRevoked || state.stopping
|
|
4581
|
+
|| $('app').hidden || $('terminal-panel').hidden || !view.el.isConnected) return null;
|
|
4582
|
+
return { term: view.term, socket: view.ws, run: session.startedAt, name: session.name };
|
|
4583
|
+
},
|
|
4584
|
+
});
|
|
4585
|
+
bindTerminalViewport($('terminal-panel'), $('terminal-controls'));
|
|
4586
|
+
|
|
4544
4587
|
const terminalCopy = new TerminalCopy({
|
|
4545
4588
|
opener: $('panel-copy'), dialog: $('terminal-copy'),
|
|
4546
4589
|
getCurrent: () => {
|
|
@@ -4578,7 +4621,7 @@ function openPanel(id, { beside = false, restoring = false } = {}) {
|
|
|
4578
4621
|
function focusPane(index, { focusTerminal = true } = {}) {
|
|
4579
4622
|
const id = state.panes[index];
|
|
4580
4623
|
if (!id) return;
|
|
4581
|
-
if (state.activeId !== id) terminalCopy.close();
|
|
4624
|
+
if (state.activeId !== id) { terminalCopy.close(); terminalControls.cancel(); }
|
|
4582
4625
|
if (dictation && dictation.id !== id) stopDictation();
|
|
4583
4626
|
state.focusedPane = index;
|
|
4584
4627
|
state.activeId = id;
|
|
@@ -4599,6 +4642,7 @@ function closePane(index, { focusTerminal = true } = {}) {
|
|
|
4599
4642
|
}
|
|
4600
4643
|
|
|
4601
4644
|
function closePanel() {
|
|
4645
|
+
terminalControls.cancel();
|
|
4602
4646
|
terminalCopy.close();
|
|
4603
4647
|
stopDictation();
|
|
4604
4648
|
for (const id of state.panes) state.views.get(id)?.unmount();
|
|
@@ -4613,7 +4657,8 @@ function closePanel() {
|
|
|
4613
4657
|
|
|
4614
4658
|
function stageSplit() {
|
|
4615
4659
|
const box = $('terminal-panel').getBoundingClientRect();
|
|
4616
|
-
|
|
4660
|
+
const controls = $('terminal-controls');
|
|
4661
|
+
return splitMode(box.width, box.height - (controls.hidden ? 0 : controls.offsetHeight));
|
|
4617
4662
|
}
|
|
4618
4663
|
|
|
4619
4664
|
function layoutPanes() {
|
|
@@ -4709,6 +4754,7 @@ function restorePanes() {
|
|
|
4709
4754
|
}
|
|
4710
4755
|
|
|
4711
4756
|
function updatePanel() {
|
|
4757
|
+
terminalControls.refresh();
|
|
4712
4758
|
renderVoice();
|
|
4713
4759
|
const s = state.sessions.get(state.activeId);
|
|
4714
4760
|
if (!s) return;
|
|
@@ -5222,7 +5268,12 @@ bindSplitter($('pane-splitter'), {
|
|
|
5222
5268
|
home: () => setSplitRatio(SPLIT_RATIO_MIN),
|
|
5223
5269
|
endKey: () => setSplitRatio(1 - SPLIT_RATIO_MIN),
|
|
5224
5270
|
});
|
|
5225
|
-
|
|
5271
|
+
let terminalLayoutFrame;
|
|
5272
|
+
const terminalLayoutObserver = new ResizeObserver(() => {
|
|
5273
|
+
cancelAnimationFrame(terminalLayoutFrame);
|
|
5274
|
+
terminalLayoutFrame = requestAnimationFrame(layoutPanes);
|
|
5275
|
+
});
|
|
5276
|
+
for (const node of [$('terminal-panel'), $('terminal-controls')]) terminalLayoutObserver.observe(node);
|
|
5226
5277
|
$('panel-voice').addEventListener('click', () => {
|
|
5227
5278
|
if (dictation) {
|
|
5228
5279
|
const { id } = dictation;
|
|
@@ -5570,8 +5621,13 @@ setInterval(() => { if (dockShows('github') && state.github) renderGitHub(); },
|
|
|
5570
5621
|
// The terminal panel and the dock sit below the top bar; publish where it ends so they never cover its controls.
|
|
5571
5622
|
const topbar = document.querySelector('.topbar');
|
|
5572
5623
|
const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${Math.max(0, Math.round(topbar.getBoundingClientRect().bottom))}px`);
|
|
5573
|
-
|
|
5574
|
-
|
|
5624
|
+
let topbarFrame;
|
|
5625
|
+
const scheduleTopbarHeight = () => {
|
|
5626
|
+
cancelAnimationFrame(topbarFrame);
|
|
5627
|
+
topbarFrame = requestAnimationFrame(publishTopbarHeight);
|
|
5628
|
+
};
|
|
5629
|
+
new ResizeObserver(scheduleTopbarHeight).observe(topbar);
|
|
5630
|
+
addEventListener('scroll', scheduleTopbarHeight, { passive: true });
|
|
5575
5631
|
publishTopbarHeight();
|
|
5576
5632
|
applyDockLayout();
|
|
5577
5633
|
|
package/web/index.html
CHANGED
|
@@ -169,6 +169,14 @@
|
|
|
169
169
|
<div class="terminal-host"></div>
|
|
170
170
|
</section>
|
|
171
171
|
</div>
|
|
172
|
+
<div id="terminal-controls" class="terminal-controls" role="group" aria-label="Terminal keys" hidden>
|
|
173
|
+
<button class="btn terminal-key" type="button" data-key="ArrowLeft" aria-label="Left arrow" title="Left arrow" disabled>←</button>
|
|
174
|
+
<button class="btn terminal-key" type="button" data-key="ArrowUp" aria-label="Up arrow" title="Up arrow" disabled>↑</button>
|
|
175
|
+
<button class="btn terminal-key" type="button" data-key="ArrowDown" aria-label="Down arrow" title="Down arrow" disabled>↓</button>
|
|
176
|
+
<button class="btn terminal-key" type="button" data-key="ArrowRight" aria-label="Right arrow" title="Right arrow" disabled>→</button>
|
|
177
|
+
<button class="btn terminal-key" type="button" data-key="Enter" title="Send Enter to the terminal" disabled>Enter</button>
|
|
178
|
+
<button class="btn terminal-key" type="button" data-key="Escape" aria-label="Escape" title="Send Escape to the terminal" disabled>Esc</button>
|
|
179
|
+
</div>
|
|
172
180
|
<div id="voice-preview" class="voice-preview" hidden aria-live="polite"></div>
|
|
173
181
|
</div>
|
|
174
182
|
|
package/web/styles.css
CHANGED
|
@@ -443,7 +443,7 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
|
|
|
443
443
|
|
|
444
444
|
.terminal-panel {
|
|
445
445
|
/* Below the top bar, whose bottom edge the page publishes as --topbar-h. */
|
|
446
|
-
position: fixed; inset: calc(var(--topbar-h, 52px) + 12px) calc(16px + var(--dock-stage-space, 0px)) 16px 16px; z-index: 10; display: flex; flex-direction: column;
|
|
446
|
+
position: fixed; inset: max(calc(var(--topbar-h, 52px) + 12px), var(--terminal-viewport-top, 0px)) calc(16px + var(--dock-stage-space, 0px)) calc(16px + var(--terminal-viewport-bottom, 0px)) 16px; z-index: 10; display: flex; flex-direction: column;
|
|
447
447
|
background: var(--term-bg); border: 1px solid var(--border); border-radius: var(--radius);
|
|
448
448
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); overflow: hidden;
|
|
449
449
|
}
|
|
@@ -518,6 +518,30 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
|
|
|
518
518
|
}
|
|
519
519
|
.terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
|
|
520
520
|
.terminal-host > div { height: 100%; }
|
|
521
|
+
.terminal-controls {
|
|
522
|
+
flex: none; display: flex; justify-content: center; gap: 4px;
|
|
523
|
+
padding: 6px max(8px, env(safe-area-inset-right)) max(6px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
|
|
524
|
+
border-top: 1px solid var(--border); background: var(--surface);
|
|
525
|
+
touch-action: manipulation; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
|
|
526
|
+
}
|
|
527
|
+
.terminal-controls .terminal-key {
|
|
528
|
+
flex: 1 1 0; min-width: 44px; max-width: 64px; min-height: 48px; padding: 4px;
|
|
529
|
+
font: 600 0.85rem var(--font-mono); touch-action: manipulation;
|
|
530
|
+
}
|
|
531
|
+
.terminal-key[data-key^="Arrow"] { font-size: 1.3rem; }
|
|
532
|
+
.terminal-key[data-key="Enter"] { flex-grow: 1.25; max-width: 80px; }
|
|
533
|
+
.terminal-key.pressed:not(:disabled) { background: var(--accent-fill); border-color: var(--accent-fill); color: var(--accent-text); transform: translateY(1px); }
|
|
534
|
+
.terminal-key:disabled { cursor: default; }
|
|
535
|
+
.terminal-panel:has(.terminal-controls:not([hidden])) .voice-preview { bottom: calc(72px + env(safe-area-inset-bottom)); }
|
|
536
|
+
/* A docked keyboard in landscape leaves little height. Keep the session and actions
|
|
537
|
+
available in one header row; Hide still returns to the normal page. */
|
|
538
|
+
.terminal-panel[data-compact] { top: var(--terminal-viewport-top, 0px); }
|
|
539
|
+
.terminal-panel[data-compact] .panel-bar { flex-wrap: nowrap; gap: 6px; padding: 6px 8px; }
|
|
540
|
+
.terminal-panel[data-compact] .panel-title { flex: 1 1 0; }
|
|
541
|
+
.terminal-panel[data-compact] :is(.panel-title .sub, .panel-title .agents, .panel-title .provider-icon) { display: none; }
|
|
542
|
+
.terminal-panel[data-compact] .panel-actions { flex-wrap: nowrap; gap: 4px; max-width: 65%; overflow-x: auto; }
|
|
543
|
+
.terminal-panel[data-compact] .panel-actions .btn { flex: none; }
|
|
544
|
+
.terminal-panel[data-compact] #panel-close { position: sticky; right: 0; z-index: 1; box-shadow: -4px 0 0 var(--surface); }
|
|
521
545
|
.terminal-copy {
|
|
522
546
|
width: min(880px, calc(100vw - 24px)); height: min(720px, calc(100dvh - 24px));
|
|
523
547
|
max-width: none; max-height: none; padding: 0; overflow: hidden;
|
|
@@ -545,7 +569,7 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
|
|
|
545
569
|
}
|
|
546
570
|
@media (pointer: coarse) and (max-width: 900px) {
|
|
547
571
|
/* Tablets in portrait are wider than the phone layout but still want the whole width. */
|
|
548
|
-
.terminal-panel { inset: var(--topbar-h, 56px) 0
|
|
572
|
+
.terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
|
|
549
573
|
}
|
|
550
574
|
|
|
551
575
|
.models {
|
|
@@ -558,7 +582,8 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
|
|
|
558
582
|
.models-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
559
583
|
.models-heading { flex: 1; min-width: 0; }
|
|
560
584
|
.models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
|
|
561
|
-
|
|
585
|
+
/* Content supplies the basis for auto-height dialogs; a zero basis collapses in Safari. */
|
|
586
|
+
.models-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 14px 14px; }
|
|
562
587
|
.remote-access { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
|
|
563
588
|
.remote-body { padding: 4px 20px 20px; font-size: 0.9rem; }
|
|
564
589
|
.remote-body > p { color: var(--muted); line-height: 1.6; }
|
|
@@ -968,7 +993,7 @@ a.btn.github-out::after {
|
|
|
968
993
|
@media (max-width: 640px) {
|
|
969
994
|
.topbar { padding: 10px 16px; }
|
|
970
995
|
.connection .label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
971
|
-
.terminal-panel { inset: var(--topbar-h, 56px) 0
|
|
996
|
+
.terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
|
|
972
997
|
.session-grid { grid-template-columns: 1fr; }
|
|
973
998
|
.cwd { flex-basis: 100%; }
|
|
974
999
|
.launch-tools { flex-basis: 100%; justify-content: stretch; }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const ARROWS = { ArrowUp: 'A', ArrowDown: 'B', ArrowRight: 'C', ArrowLeft: 'D' };
|
|
2
|
+
|
|
3
|
+
/** Use the same cursor mode as a physical keyboard, including inside full-screen tools. */
|
|
4
|
+
export function terminalKey(key, modes) {
|
|
5
|
+
if (Object.hasOwn(ARROWS, key)) return `\x1b${modes.applicationCursorKeysMode ? 'O' : '['}${ARROWS[key]}`;
|
|
6
|
+
if (key === 'Enter') return '\r';
|
|
7
|
+
if (key === 'Escape') return '\x1b';
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const sameTarget = (a, b) => Boolean(a && b && a.term === b.term && a.socket === b.socket && a.run === b.run);
|
|
12
|
+
|
|
13
|
+
/** Six ordinary buttons. A gesture belongs to the session and connection where it began. */
|
|
14
|
+
export class TerminalControls {
|
|
15
|
+
constructor({ element, panel, getCurrent }) {
|
|
16
|
+
this.element = element;
|
|
17
|
+
this.getCurrent = getCurrent;
|
|
18
|
+
this.buttons = [...element.querySelectorAll('button')];
|
|
19
|
+
const touch = matchMedia('(any-pointer: coarse)');
|
|
20
|
+
const availability = () => {
|
|
21
|
+
element.hidden = !touch.matches && !navigator.maxTouchPoints;
|
|
22
|
+
this.refresh();
|
|
23
|
+
};
|
|
24
|
+
touch.addEventListener('change', availability);
|
|
25
|
+
for (const button of this.buttons) {
|
|
26
|
+
button.addEventListener('pointerdown', (event) => {
|
|
27
|
+
this.cancel();
|
|
28
|
+
if (!event.isPrimary || event.button !== 0 || button.disabled) return;
|
|
29
|
+
// Retain xterm's focus if typing, and leave the keyboard closed if it is closed.
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
const target = this.current();
|
|
32
|
+
if (!target) return;
|
|
33
|
+
this.press = { button, target, pointer: event.pointerId };
|
|
34
|
+
button.setPointerCapture(event.pointerId);
|
|
35
|
+
button.classList.add('pressed');
|
|
36
|
+
});
|
|
37
|
+
button.addEventListener('pointermove', (event) => {
|
|
38
|
+
if (this.press?.pointer === event.pointerId && document.elementFromPoint(event.clientX, event.clientY) !== button) this.cancel();
|
|
39
|
+
});
|
|
40
|
+
button.addEventListener('pointerup', (event) => {
|
|
41
|
+
const press = this.press;
|
|
42
|
+
if (press?.pointer !== event.pointerId) return;
|
|
43
|
+
this.cancel();
|
|
44
|
+
if (document.elementFromPoint(event.clientX, event.clientY) === button) this.activate(button, press.target);
|
|
45
|
+
});
|
|
46
|
+
for (const type of ['pointercancel', 'lostpointercapture', 'blur']) button.addEventListener(type, () => this.cancel());
|
|
47
|
+
button.addEventListener('keydown', (event) => {
|
|
48
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
if (event.repeat) return;
|
|
51
|
+
this.cancel();
|
|
52
|
+
this.press = { button, target: this.current(), key: event.key };
|
|
53
|
+
button.classList.add('pressed');
|
|
54
|
+
});
|
|
55
|
+
button.addEventListener('keyup', (event) => {
|
|
56
|
+
const press = this.press;
|
|
57
|
+
if (!press || press.key !== event.key || press.button !== button) return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
this.cancel();
|
|
60
|
+
this.activate(button, press.target);
|
|
61
|
+
});
|
|
62
|
+
// Pointer and keyboard releases were handled above. Keep assistive-technology
|
|
63
|
+
// activation working without sending a second key for the following click.
|
|
64
|
+
button.addEventListener('click', (event) => {
|
|
65
|
+
if (event.detail === 0 && !event.pointerType) this.activate(button, this.current());
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
addEventListener('blur', () => this.cancel());
|
|
69
|
+
addEventListener('pagehide', () => this.cancel());
|
|
70
|
+
document.addEventListener('visibilitychange', () => { this.cancel(); this.refresh(); });
|
|
71
|
+
// Modal Copy (and other dialogs) must cancel a press even if it closes before release.
|
|
72
|
+
new MutationObserver(() => { this.cancel(); this.refresh(); }).observe(document.body, {
|
|
73
|
+
subtree: true, attributes: true, attributeFilter: ['open'],
|
|
74
|
+
});
|
|
75
|
+
new MutationObserver(() => this.refresh()).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
|
|
76
|
+
availability();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
current() {
|
|
80
|
+
if (this.element.hidden || document.visibilityState !== 'visible' || document.querySelector('dialog[open]')) return null;
|
|
81
|
+
return this.getCurrent();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
refresh() {
|
|
85
|
+
const target = this.current();
|
|
86
|
+
if (!sameTarget(this.press?.target, target)) this.cancel();
|
|
87
|
+
for (const button of this.buttons) button.disabled = !target;
|
|
88
|
+
this.element.setAttribute('aria-label', target ? `Terminal keys for ${target.name}` : 'Terminal keys unavailable');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
cancel() {
|
|
92
|
+
this.press?.button.classList.remove('pressed');
|
|
93
|
+
this.press = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
activate(button, target) {
|
|
97
|
+
if (!sameTarget(target, this.current())) return;
|
|
98
|
+
const data = terminalKey(button.dataset.key, target.term.modes);
|
|
99
|
+
if (data !== null) target.term.input(data);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fit the touch panel to a docked keyboard without changing global page layout. */
|
|
104
|
+
export function bindTerminalViewport(panel, controls) {
|
|
105
|
+
const viewport = window.visualViewport;
|
|
106
|
+
let frame;
|
|
107
|
+
const update = () => {
|
|
108
|
+
if (panel.hidden || controls.hidden) {
|
|
109
|
+
panel.style.removeProperty('--terminal-viewport-top');
|
|
110
|
+
panel.style.removeProperty('--terminal-viewport-bottom');
|
|
111
|
+
delete panel.dataset.compact;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Magnification belongs to the browser; it must not resize the shared PTY.
|
|
115
|
+
if (viewport && Math.abs(viewport.scale - 1) > 0.01) return;
|
|
116
|
+
const top = viewport?.offsetTop || 0;
|
|
117
|
+
const height = viewport?.height ?? innerHeight;
|
|
118
|
+
panel.style.setProperty('--terminal-viewport-top', `${top}px`);
|
|
119
|
+
panel.style.setProperty('--terminal-viewport-bottom', `${Math.max(0, innerHeight - top - height)}px`);
|
|
120
|
+
panel.toggleAttribute('data-compact', height < 420);
|
|
121
|
+
};
|
|
122
|
+
const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(update); };
|
|
123
|
+
viewport?.addEventListener('resize', schedule);
|
|
124
|
+
viewport?.addEventListener('scroll', schedule);
|
|
125
|
+
addEventListener('resize', schedule);
|
|
126
|
+
addEventListener('pageshow', schedule);
|
|
127
|
+
const observer = new MutationObserver(schedule);
|
|
128
|
+
for (const element of [panel, controls]) observer.observe(element, { attributes: true, attributeFilter: ['hidden'] });
|
|
129
|
+
schedule();
|
|
130
|
+
}
|