@oddessentials/agent-guild 0.24.0 → 0.24.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 +5 -0
- package/package.json +1 -1
- package/web/app.js +13 -0
- package/web/index.html +16 -0
- package/web/styles.css +21 -0
- package/web/terminal-copy.js +128 -0
package/README.md
CHANGED
|
@@ -60,6 +60,11 @@ and come back to the same screens. Drag a session card by its grip, with a
|
|
|
60
60
|
mouse or a finger, to put the cards in the order you want; this browser
|
|
61
61
|
remembers it, and new sessions join at the end.
|
|
62
62
|
|
|
63
|
+
On a touchscreen, **Copy…** opens a still copy of the terminal text. Touch and
|
|
64
|
+
hold to select, then use **Copy selection** or the browser's Copy menu. **Done**
|
|
65
|
+
returns to the live terminal; the session keeps running while you copy. The
|
|
66
|
+
view includes retained scrollback, or the current screen of a full-screen tool.
|
|
67
|
+
|
|
63
68
|
**Pick your shell.** The Shell card offers the shells installed on your
|
|
64
69
|
computer and remembers your choice in this browser. On Windows it defaults
|
|
65
70
|
to PowerShell 7 when installed, then Windows PowerShell; Git Bash and Command
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// it never owns sessions, so closing the page leaves them running.
|
|
3
3
|
|
|
4
4
|
import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
|
|
5
|
+
import { TerminalCopy } from './terminal-copy.js';
|
|
5
6
|
|
|
6
7
|
const TOKEN_KEY = 'agentGuild.token';
|
|
7
8
|
const CWD_KEY = 'agentGuild.cwd';
|
|
@@ -3591,8 +3592,18 @@ class TerminalView {
|
|
|
3591
3592
|
|
|
3592
3593
|
// ---- terminal panel -------------------------------------------------------
|
|
3593
3594
|
|
|
3595
|
+
const terminalCopy = new TerminalCopy({
|
|
3596
|
+
opener: $('panel-copy'), dialog: $('terminal-copy'),
|
|
3597
|
+
getCurrent: () => {
|
|
3598
|
+
const session = state.sessions.get(state.activeId);
|
|
3599
|
+
const view = state.views.get(state.activeId);
|
|
3600
|
+
return session && view ? { id: session.id, name: session.name, term: view.term } : null;
|
|
3601
|
+
},
|
|
3602
|
+
});
|
|
3603
|
+
|
|
3594
3604
|
function openPanel(id) {
|
|
3595
3605
|
if (!state.sessions.has(id)) return;
|
|
3606
|
+
if (state.activeId !== id) terminalCopy.close();
|
|
3596
3607
|
if (dictation && dictation.id !== id) stopDictation();
|
|
3597
3608
|
if (state.activeId && state.activeId !== id) state.views.get(state.activeId)?.unmount();
|
|
3598
3609
|
state.activeId = id;
|
|
@@ -3604,6 +3615,7 @@ function openPanel(id) {
|
|
|
3604
3615
|
}
|
|
3605
3616
|
|
|
3606
3617
|
function closePanel() {
|
|
3618
|
+
terminalCopy.close();
|
|
3607
3619
|
stopDictation();
|
|
3608
3620
|
if (state.activeId) state.views.get(state.activeId)?.unmount();
|
|
3609
3621
|
state.activeId = null;
|
|
@@ -4187,6 +4199,7 @@ document.addEventListener('visibilitychange', () => {
|
|
|
4187
4199
|
});
|
|
4188
4200
|
addEventListener('pagehide', () => {
|
|
4189
4201
|
state.pageAway = true;
|
|
4202
|
+
terminalCopy.close();
|
|
4190
4203
|
stopDictation();
|
|
4191
4204
|
managerLoss.cancel();
|
|
4192
4205
|
state.eventsSocket?.close();
|
package/web/index.html
CHANGED
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
</div>
|
|
126
126
|
<div class="panel-actions">
|
|
127
127
|
<button id="panel-voice" class="btn voice" type="button" hidden aria-pressed="false" title="Speak into this terminal. Review the text, then press Enter yourself.">Dictate</button>
|
|
128
|
+
<button id="panel-copy" class="btn" type="button" hidden aria-haspopup="dialog" title="Select and copy terminal text">Copy…</button>
|
|
128
129
|
<button id="panel-stop" class="btn danger" type="button">Stop</button>
|
|
129
130
|
<button id="panel-close" class="btn" type="button" title="Hide the terminal. The session keeps running.">Hide</button>
|
|
130
131
|
</div>
|
|
@@ -133,6 +134,21 @@
|
|
|
133
134
|
<div id="voice-preview" class="voice-preview" hidden aria-live="polite"></div>
|
|
134
135
|
</div>
|
|
135
136
|
|
|
137
|
+
<dialog id="terminal-copy" class="terminal-copy" aria-labelledby="terminal-copy-title">
|
|
138
|
+
<div class="terminal-copy-bar">
|
|
139
|
+
<div class="terminal-copy-heading">
|
|
140
|
+
<h2 id="terminal-copy-title">Copy terminal text</h2>
|
|
141
|
+
<p class="sub" data-session></p>
|
|
142
|
+
</div>
|
|
143
|
+
<button class="btn" type="button" data-done>Done</button>
|
|
144
|
+
</div>
|
|
145
|
+
<textarea readonly autofocus wrap="off" spellcheck="false" inputmode="none" aria-label="Terminal text snapshot" aria-describedby="terminal-copy-status"></textarea>
|
|
146
|
+
<div class="terminal-copy-footer">
|
|
147
|
+
<p id="terminal-copy-status" role="status"></p>
|
|
148
|
+
<button class="btn primary" type="button" data-copy disabled>Copy selection</button>
|
|
149
|
+
</div>
|
|
150
|
+
</dialog>
|
|
151
|
+
|
|
136
152
|
<dialog id="models" class="models" aria-labelledby="models-title">
|
|
137
153
|
<div class="models-bar">
|
|
138
154
|
<span id="models-icon" class="provider-icon small"></span>
|
package/web/styles.css
CHANGED
|
@@ -418,6 +418,27 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
|
|
|
418
418
|
}
|
|
419
419
|
.terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
|
|
420
420
|
.terminal-host > div { height: 100%; }
|
|
421
|
+
.terminal-copy {
|
|
422
|
+
width: min(880px, calc(100vw - 24px)); height: min(720px, calc(100dvh - 24px));
|
|
423
|
+
max-width: none; max-height: none; padding: 0; overflow: hidden;
|
|
424
|
+
color: var(--text); background: var(--surface); border: 1px solid var(--border);
|
|
425
|
+
border-radius: var(--radius); box-shadow: var(--shadow);
|
|
426
|
+
}
|
|
427
|
+
.terminal-copy[open] { display: flex; flex-direction: column; }
|
|
428
|
+
.terminal-copy::backdrop { background: rgba(15, 17, 21, 0.5); }
|
|
429
|
+
.terminal-copy-bar, .terminal-copy-footer { display: flex; align-items: center; gap: 12px; padding: 12px 16px; }
|
|
430
|
+
.terminal-copy-heading { flex: 1; min-width: 0; }
|
|
431
|
+
.terminal-copy-heading .sub { margin: 4px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 0.8rem; }
|
|
432
|
+
.terminal-copy textarea {
|
|
433
|
+
flex: 1; min-height: 0; width: 100%; resize: none; margin: 0; padding: 14px 16px;
|
|
434
|
+
border: 0; border-block: 1px solid var(--border); border-radius: 0;
|
|
435
|
+
background: var(--term-bg); color: #e6e9ef; font: 14px/1.5 var(--font-mono);
|
|
436
|
+
white-space: pre; user-select: text; -webkit-user-select: text; touch-action: auto; overscroll-behavior: contain;
|
|
437
|
+
}
|
|
438
|
+
.terminal-copy textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
439
|
+
.terminal-copy-footer p { flex: 1; margin: 0; color: var(--muted); font-size: 0.8rem; }
|
|
440
|
+
.terminal-copy .btn { min-height: 44px; flex-shrink: 0; }
|
|
441
|
+
.terminal-copy .btn:disabled { cursor: default; }
|
|
421
442
|
@media (pointer: coarse) {
|
|
422
443
|
/* A drag on the panel would otherwise scroll the page behind it. */
|
|
423
444
|
body:has(#terminal-panel:not([hidden])) { overflow: hidden; }
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A frozen copy surface keeps native touch selection separate from terminal gestures.
|
|
2
|
+
const SELECT_HINT = 'Touch and hold to select text.';
|
|
3
|
+
|
|
4
|
+
export function captureTerminalText(term) {
|
|
5
|
+
const buffer = term.buffer.active;
|
|
6
|
+
const lines = [];
|
|
7
|
+
let viewportLine = 0;
|
|
8
|
+
let viewportPrefix = '';
|
|
9
|
+
for (let y = 0; y < buffer.length; y++) {
|
|
10
|
+
const line = buffer.getLine(y);
|
|
11
|
+
const text = line.translateToString(true, 0, term.cols);
|
|
12
|
+
const wrapped = line.isWrapped && lines.length > 0;
|
|
13
|
+
if (y === buffer.viewportY) {
|
|
14
|
+
viewportLine = wrapped ? lines.length - 1 : lines.length;
|
|
15
|
+
// The visible row can begin inside a joined line. Keep its actual text
|
|
16
|
+
// prefix: terminal cells and JavaScript character counts differ for Unicode.
|
|
17
|
+
viewportPrefix = wrapped ? lines[lines.length - 1] : '';
|
|
18
|
+
}
|
|
19
|
+
if (wrapped) lines[lines.length - 1] += text;
|
|
20
|
+
else lines.push(text);
|
|
21
|
+
}
|
|
22
|
+
return { text: lines.join('\n'), viewportLine, viewportPrefix };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class TerminalCopy {
|
|
26
|
+
constructor({ opener, dialog, getCurrent }) {
|
|
27
|
+
this.dialog = dialog;
|
|
28
|
+
this.getCurrent = getCurrent;
|
|
29
|
+
this.text = dialog.querySelector('textarea');
|
|
30
|
+
this.copyButton = dialog.querySelector('[data-copy]');
|
|
31
|
+
this.status = dialog.querySelector('[role="status"]');
|
|
32
|
+
this.current = null;
|
|
33
|
+
this.attempt = null;
|
|
34
|
+
const touch = matchMedia('(any-pointer: coarse)');
|
|
35
|
+
const availability = () => { opener.hidden = !touch.matches && !navigator.maxTouchPoints; };
|
|
36
|
+
touch.addEventListener('change', availability);
|
|
37
|
+
availability();
|
|
38
|
+
opener.addEventListener('click', () => this.open());
|
|
39
|
+
this.copyButton.addEventListener('click', () => this.copy());
|
|
40
|
+
dialog.querySelector('[data-done]').addEventListener('click', () => this.close(true));
|
|
41
|
+
dialog.addEventListener('cancel', (event) => { event.preventDefault(); this.close(true); });
|
|
42
|
+
// A programmatic/native close can arrive after the sheet has already reopened.
|
|
43
|
+
dialog.addEventListener('close', () => { if (!dialog.open) this.clear(); });
|
|
44
|
+
this.text.addEventListener('select', () => this.selectionChanged());
|
|
45
|
+
document.addEventListener('selectionchange', () => {
|
|
46
|
+
if (dialog.open) this.selectionChanged();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
open() {
|
|
51
|
+
if (this.dialog.open) return;
|
|
52
|
+
const current = this.getCurrent();
|
|
53
|
+
if (!current) return;
|
|
54
|
+
const snapshot = captureTerminalText(current.term);
|
|
55
|
+
this.current = current;
|
|
56
|
+
this.selection = '0:0';
|
|
57
|
+
this.text.value = snapshot.text;
|
|
58
|
+
this.dialog.querySelector('[data-session]').textContent = current.name;
|
|
59
|
+
this.status.textContent = SELECT_HINT;
|
|
60
|
+
this.copyButton.disabled = true;
|
|
61
|
+
this.dialog.showModal();
|
|
62
|
+
this.text.focus({ preventScroll: true });
|
|
63
|
+
this.text.setSelectionRange(0, 0);
|
|
64
|
+
const style = getComputedStyle(this.text);
|
|
65
|
+
this.text.scrollTop = snapshot.viewportLine * parseFloat(style.lineHeight);
|
|
66
|
+
this.text.scrollLeft = 0;
|
|
67
|
+
if (snapshot.viewportPrefix) {
|
|
68
|
+
const measure = document.createElement('canvas').getContext('2d');
|
|
69
|
+
measure.font = style.font;
|
|
70
|
+
this.text.scrollLeft = measure.measureText(snapshot.viewportPrefix).width;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
selectionChanged() {
|
|
75
|
+
const selection = `${this.text.selectionStart}:${this.text.selectionEnd}`;
|
|
76
|
+
if (selection !== this.selection && this.status.textContent === 'Copied') {
|
|
77
|
+
this.status.textContent = SELECT_HINT;
|
|
78
|
+
}
|
|
79
|
+
this.selection = selection;
|
|
80
|
+
this.copyButton.disabled = Boolean(this.attempt) || this.text.selectionStart === this.text.selectionEnd;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async copy() {
|
|
84
|
+
const current = this.current;
|
|
85
|
+
if (!current || this.attempt) return;
|
|
86
|
+
const range = `${this.text.selectionStart}:${this.text.selectionEnd}`;
|
|
87
|
+
const selected = this.text.value.slice(this.text.selectionStart, this.text.selectionEnd);
|
|
88
|
+
if (!selected) return;
|
|
89
|
+
const attempt = this.attempt = {};
|
|
90
|
+
this.copyButton.disabled = true;
|
|
91
|
+
this.status.textContent = 'Copying…';
|
|
92
|
+
try {
|
|
93
|
+
// Stay in the user's click activation; no permission preflight or clipboard read.
|
|
94
|
+
// Textareas normalize newlines to LF; match xterm's Windows copy convention.
|
|
95
|
+
const text = /Win/.test(navigator.platform) ? selected.replace(/\n/g, '\r\n') : selected;
|
|
96
|
+
await navigator.clipboard.writeText(text);
|
|
97
|
+
if (this.current === current && this.attempt === attempt) {
|
|
98
|
+
this.status.textContent = range === `${this.text.selectionStart}:${this.text.selectionEnd}` ? 'Copied' : SELECT_HINT;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
if (this.current === current && this.attempt === attempt) {
|
|
102
|
+
this.status.textContent = 'Use Copy in the text selection menu.';
|
|
103
|
+
this.text.focus({ preventScroll: true });
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
if (this.attempt === attempt) {
|
|
107
|
+
this.attempt = null;
|
|
108
|
+
this.selectionChanged();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
close(restoreFocus = false) {
|
|
114
|
+
const current = this.current;
|
|
115
|
+
this.clear();
|
|
116
|
+
if (this.dialog.open) this.dialog.close();
|
|
117
|
+
if (restoreFocus && current && this.getCurrent()?.term === current.term) current.term.focus();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
clear() {
|
|
121
|
+
this.current = null;
|
|
122
|
+
this.attempt = null;
|
|
123
|
+
this.text.value = '';
|
|
124
|
+
this.status.textContent = '';
|
|
125
|
+
this.dialog.querySelector('[data-session]').textContent = '';
|
|
126
|
+
this.copyButton.disabled = true;
|
|
127
|
+
}
|
|
128
|
+
}
|