@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 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.0",
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
+ }