@oddessentials/agent-guild 0.27.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 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.27.0",
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": {
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
6
+ const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) || (a.name < b.name ? -1 : 1);
7
+
8
+ export function folderSegments(dir, paths = path) {
9
+ const root = paths.parse(dir).root;
10
+ const segments = [{ name: root, path: root }];
11
+ let current = root;
12
+ for (const part of dir.slice(root.length).split(paths.sep).filter(Boolean)) {
13
+ current = paths.join(current, part);
14
+ segments.push({ name: part, path: current });
15
+ }
16
+ return segments;
17
+ }
18
+
19
+ export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises } = {}) {
20
+ const paths = platform === 'win32' ? path.win32 : path.posix;
21
+
22
+ async function roots() {
23
+ if (platform !== 'win32') return [{ name: '/', path: '/' }];
24
+ const letters = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
25
+ const found = await Promise.all(letters.map((letter) => {
26
+ let timer;
27
+ const probe = fsp.stat(`${letter}:\\`).then((stat) => stat.isDirectory(), () => false);
28
+ const late = new Promise((resolve) => { timer = setTimeout(resolve, driveTimeoutMs, false); });
29
+ return Promise.race([probe, late]).finally(() => clearTimeout(timer));
30
+ }));
31
+ return letters.filter((_, i) => found[i]).map((letter) => ({ name: `${letter}:`, path: `${letter}:\\` }));
32
+ }
33
+
34
+ async function nearestFolder(dir) {
35
+ for (;;) {
36
+ try {
37
+ if ((await fsp.stat(dir)).isDirectory()) return dir;
38
+ } catch (error) {
39
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw failure(409, 'folder_unreadable', `Agent Guild cannot read ${dir}.`);
40
+ }
41
+ const parent = paths.dirname(dir);
42
+ if (parent === dir) return home;
43
+ dir = parent;
44
+ }
45
+ }
46
+
47
+ async function entries(dir) {
48
+ let listed;
49
+ try {
50
+ listed = await fsp.readdir(dir, { withFileTypes: true });
51
+ } catch {
52
+ throw failure(409, 'folder_unreadable', `Agent Guild cannot read ${dir}.`);
53
+ }
54
+ const folders = await Promise.all(listed.map(async (entry) => {
55
+ let folder = entry.isDirectory();
56
+ if (!folder && entry.isSymbolicLink()) folder = await fsp.stat(paths.join(dir, entry.name)).then((stat) => stat.isDirectory(), () => false);
57
+ return folder ? { name: entry.name, path: paths.join(dir, entry.name), hidden: entry.name.startsWith('.') } : null;
58
+ }));
59
+ return folders.filter(Boolean).sort(byName);
60
+ }
61
+
62
+ return {
63
+ async list(raw) {
64
+ if (raw !== undefined && raw !== null && (typeof raw !== 'string' || raw.includes('\0'))) {
65
+ throw failure(400, 'bad_path', 'The folder must be a path.');
66
+ }
67
+ let asked = String(raw ?? '').trim();
68
+ if (asked === '~' || asked.startsWith('~/') || asked.startsWith('~\\')) asked = paths.join(home, asked.slice(1));
69
+ const wanted = asked ? paths.resolve(asked) : home;
70
+ const dir = await nearestFolder(wanted);
71
+ const [folders, drives] = await Promise.all([entries(dir), roots()]);
72
+ const parent = paths.dirname(dir);
73
+ return {
74
+ path: dir,
75
+ parent: parent === dir ? null : parent,
76
+ home,
77
+ segments: folderSegments(dir, paths),
78
+ roots: drives,
79
+ entries: folders.slice(0, limit),
80
+ truncated: folders.length > limit,
81
+ note: dir === wanted ? null : wanted,
82
+ };
83
+ },
84
+ };
85
+ }
@@ -10,7 +10,7 @@ import { timingSafeEqualString } from './session-manager.mjs';
10
10
  import { folderOrigin } from './github.mjs';
11
11
  import { createViews } from './github-views.mjs';
12
12
  import { createFolderOpener } from './folder-opener.mjs';
13
- import { createFolderPicker } from './folder-picker.mjs';
13
+ import { createFolderBrowser } from './folder-browser.mjs';
14
14
  import { normalizeAccess } from './access-policy.mjs';
15
15
 
16
16
  const require = createRequire(import.meta.url);
@@ -114,7 +114,7 @@ export function createManagerServer({
114
114
  extraOrigins = [],
115
115
  remoteAccess = null,
116
116
  folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
117
- folderPicker = createFolderPicker({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
117
+ folderBrowser = createFolderBrowser(),
118
118
  /** The double-click launcher file for this platform, or null when the package carries none. */
119
119
  launcher = null,
120
120
  /** @type {(opts: { restart: boolean }) => void} */
@@ -133,7 +133,13 @@ export function createManagerServer({
133
133
  const vendor = vendorFiles();
134
134
  let boundPort = port;
135
135
 
136
- const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...access.hosts]);
136
+ const localHosts = () => [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`];
137
+ const allowedHosts = () => new Set([...localHosts(), ...access.hosts]);
138
+ const isLocalClient = (req) => localHosts().includes(String(req.headers.host || '').toLowerCase());
139
+ const folderOpenerFor = (req) => {
140
+ const opener = folderOpener.describe();
141
+ return isLocalClient(req) ? opener : { ...opener, available: false, reason: 'Only available on the computer running Agent Guild.' };
142
+ };
137
143
  const allowedOrigins = () =>
138
144
  new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`, ...access.origins]);
139
145
 
@@ -268,21 +274,18 @@ export function createManagerServer({
268
274
  warnings: registry.warnings,
269
275
  upgrade: upgradeInfo(),
270
276
  launcher,
271
- folderOpener: folderOpener.describe(),
272
- folderPicker: folderPicker.describe(),
277
+ folderOpener: folderOpenerFor(req),
273
278
  remoteAccess: remoteAccess ? { available: true } : null,
274
279
  });
275
280
  }
276
281
  if (route === '/open-folder' && method === 'POST') {
277
282
  const { cwd } = await readJsonBody(req);
283
+ if (!isLocalClient(req)) throw new HttpError(403, 'Only available on the computer running Agent Guild.', 'local_only');
278
284
  await folderOpener.open(cwd);
279
285
  return sendJson(res, 200, { ok: true });
280
286
  }
281
- if (route === '/pick-folder' && method === 'POST') {
282
- const { cwd } = await readJsonBody(req);
283
- const gone = new AbortController();
284
- res.once('close', () => gone.abort());
285
- return sendJson(res, 200, { path: await folderPicker.pick(cwd, { signal: gone.signal }) });
287
+ if (route === '/folders' && method === 'GET') {
288
+ return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined));
286
289
  }
287
290
  if (route === '/upgrade' && method === 'POST') {
288
291
  const session = await manager.upgrade();
@@ -530,9 +533,9 @@ export function createManagerServer({
530
533
  github?.on('updated', () => broadcast({ type: 'github.updated' }));
531
534
  remoteAccess?.on('updated', () => broadcast({ type: 'remote-access.updated' }));
532
535
 
533
- function handleEvents(ws) {
536
+ function handleEvents(ws, req) {
534
537
  eventClients.add(ws);
535
- safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, folderOpener: folderOpener.describe(), folderPicker: folderPicker.describe(), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() });
538
+ safeSend(ws, { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() });
536
539
  ws.on('close', () => eventClients.delete(ws));
537
540
  ws.on('message', () => { /* events socket is server -> client only */ });
538
541
  }
@@ -582,7 +585,7 @@ export function createManagerServer({
582
585
  ws.on('pong', () => { ws.isAlive = true; });
583
586
  ws.on('error', () => {});
584
587
  if (termMatch) handleTerminal(ws, termMatch[1]);
585
- else handleEvents(ws);
588
+ else handleEvents(ws, req);
586
589
  });
587
590
  });
588
591
 
package/web/app.js CHANGED
@@ -3,10 +3,12 @@
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';
9
10
  import { createRemoteAccessUI } from './remote-access.js';
11
+ import { matchFolders, readRecentFolders, rememberFolder } from './folders.js';
10
12
 
11
13
  const TOKEN_KEY = 'agentGuild.token';
12
14
  const CWD_KEY = 'agentGuild.cwd';
@@ -19,6 +21,8 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
19
21
  const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
20
22
  const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
21
23
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
24
+ const HIDDEN_FOLDERS_KEY = 'agentGuild.showHiddenFolders';
25
+ const RECENT_CWDS_KEY = 'agentGuild.recentCwds';
22
26
  const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
23
27
  const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
24
28
  const GITHUB_VIEW_KEY = 'agentGuild.githubView';
@@ -62,8 +66,8 @@ const state = {
62
66
  connected: false,
63
67
  folderOpener: null,
64
68
  folderOpening: false,
65
- folderPicker: null,
66
- folderPicking: false,
69
+ /** The manager's operating system, from `hello`. */
70
+ platform: null,
67
71
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
68
72
  upgrade: null,
69
73
  /** The running manager's version and pid, from `hello`. */
@@ -121,6 +125,7 @@ function setConnection(kind, label) {
121
125
  el.title = label;
122
126
  // The manager can only be stopped, restarted or upgraded while the page can reach it.
123
127
  state.connected = kind === 'ok';
128
+ terminalControls.refresh();
124
129
  state.remoteAccessUI?.connectionChanged();
125
130
  $('manager').hidden = !state.connected;
126
131
  if (!state.connected) closeMenu($('manager-menu'));
@@ -737,11 +742,9 @@ function renderFolderTools() {
737
742
  : !feature?.available ? feature?.reason || text.missing
738
743
  : label;
739
744
  };
740
- paint('cwd-pick', state.folderPicker, state.folderPicking, 'Choose a folder…', {
741
- offline: 'Connect to the session manager to choose folders',
742
- busy: 'Choosing a folder…',
743
- missing: 'Choosing folders is unavailable with this manager',
744
- });
745
+ for (const id of ['cwd-pick', 'github-parent-pick']) {
746
+ paint(id, { available: true }, false, 'Choose a folder…', { offline: 'Connect to the session manager to choose folders' });
747
+ }
745
748
  paint('cwd-open', state.folderOpener, state.folderOpening, `Open working folder in ${state.folderOpener?.label || 'file manager'}`, {
746
749
  offline: 'Connect to the session manager to open folders',
747
750
  busy: 'Opening working folder…',
@@ -749,22 +752,6 @@ function renderFolderTools() {
749
752
  });
750
753
  }
751
754
 
752
- async function pickWorkingFolder() {
753
- if (!state.connected || !state.folderPicker?.available || state.folderPicking) return;
754
- state.folderPicking = true;
755
- renderFolderTools();
756
- try {
757
- const { path } = await api('POST', '/pick-folder', { cwd: $('cwd').value.trim() });
758
- if (path) useFolder(path);
759
- } catch (err) {
760
- if (err instanceof AuthError) showAuth(err.message);
761
- else toast(err.message || 'Could not choose a folder.');
762
- } finally {
763
- state.folderPicking = false;
764
- renderFolderTools();
765
- }
766
- }
767
-
768
755
  async function openWorkingFolder() {
769
756
  if (!state.connected || !state.folderOpener?.available || state.folderOpening) return;
770
757
  state.folderOpening = true;
@@ -780,6 +767,186 @@ async function openWorkingFolder() {
780
767
  }
781
768
  }
782
769
 
770
+ // ---- recent working folders -----------------------------------------------
771
+
772
+ const recentView = { open: false, typed: false, active: -1 };
773
+
774
+ function recentCwds() {
775
+ return readRecentFolders(load(RECENT_CWDS_KEY));
776
+ }
777
+
778
+ function rememberCwd(dir) {
779
+ if (dir) save(RECENT_CWDS_KEY, JSON.stringify(rememberFolder(recentCwds(), dir, { caseless: state.platform === 'win32' })));
780
+ }
781
+
782
+ function renderRecentCwds() {
783
+ const input = $('cwd'), list = $('cwd-recent');
784
+ const shown = recentView.open ? matchFolders(recentCwds(), recentView.typed ? input.value : '') : [];
785
+ recentView.active = Math.min(recentView.active, shown.length - 1);
786
+ list.replaceChildren(...shown.map((dir, i) => {
787
+ const option = el('li', 'cwd-recent-option', dir);
788
+ option.id = `cwd-recent-${i}`;
789
+ option.setAttribute('role', 'option');
790
+ option.setAttribute('aria-selected', String(i === recentView.active));
791
+ option.addEventListener('pointerdown', (e) => e.preventDefault());
792
+ option.addEventListener('click', () => pickRecentCwd(dir));
793
+ return option;
794
+ }));
795
+ list.hidden = !shown.length;
796
+ input.setAttribute('aria-expanded', String(!list.hidden));
797
+ if (recentView.active >= 0) input.setAttribute('aria-activedescendant', `cwd-recent-${recentView.active}`);
798
+ else input.removeAttribute('aria-activedescendant');
799
+ }
800
+
801
+ function showRecentCwds(typed = false) {
802
+ Object.assign(recentView, { open: true, typed, active: -1 });
803
+ renderRecentCwds();
804
+ }
805
+
806
+ function hideRecentCwds() {
807
+ Object.assign(recentView, { open: false, active: -1 });
808
+ renderRecentCwds();
809
+ }
810
+
811
+ function pickRecentCwd(dir) {
812
+ hideRecentCwds();
813
+ useFolder(dir);
814
+ }
815
+
816
+ function recentCwdKeys(e) {
817
+ const options = $('cwd-recent').hidden ? [] : [...$('cwd-recent').children];
818
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
819
+ e.preventDefault();
820
+ if (!recentView.open) showRecentCwds();
821
+ const count = $('cwd-recent').children.length;
822
+ if (!count) return;
823
+ recentView.active = e.key === 'ArrowDown' ? (recentView.active + 1) % count : recentView.active <= 0 ? count - 1 : recentView.active - 1;
824
+ renderRecentCwds();
825
+ $(`cwd-recent-${recentView.active}`).scrollIntoView?.({ block: 'nearest' });
826
+ } else if (e.key === 'Enter' && recentView.active >= 0 && options[recentView.active]) {
827
+ e.preventDefault();
828
+ pickRecentCwd(options[recentView.active].textContent);
829
+ } else if (e.key === 'Escape' && options.length) {
830
+ e.preventDefault();
831
+ hideRecentCwds();
832
+ }
833
+ }
834
+
835
+ // ---- folder browser -------------------------------------------------------
836
+
837
+ const FOLDER_FIELDS = {
838
+ cwd: { title: 'Choose working folder', input: 'cwd' },
839
+ clone: { title: 'Choose clone folder', input: 'github-parent' },
840
+ };
841
+ const folderView = { field: null, listing: null, loading: false, error: null, request: 0, opener: null };
842
+
843
+ function chooseFolder(field) {
844
+ if (!state.connected) return;
845
+ const dialog = $('folder-browser');
846
+ Object.assign(folderView, { field, listing: null, loading: false, error: null });
847
+ $('folder-title').textContent = FOLDER_FIELDS[field].title;
848
+ $('folder-filter').value = '';
849
+ $('folder-hidden').checked = load(HIDDEN_FOLDERS_KEY) === '1';
850
+ if (!dialog.open) {
851
+ folderView.opener = document.activeElement;
852
+ dialog.showModal();
853
+ }
854
+ browseTo($(FOLDER_FIELDS[field].input).value.trim());
855
+ }
856
+
857
+ async function browseTo(dir) {
858
+ const request = ++folderView.request;
859
+ folderView.loading = true;
860
+ folderView.error = null;
861
+ renderFolderBrowser();
862
+ try {
863
+ const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}`);
864
+ if (request !== folderView.request) return;
865
+ folderView.listing = listing;
866
+ $('folder-filter').value = '';
867
+ } catch (err) {
868
+ if (request !== folderView.request) return;
869
+ if (err instanceof AuthError) {
870
+ $('folder-browser').close();
871
+ return showAuth(err.message);
872
+ }
873
+ folderView.error = err.message || 'Could not list this folder.';
874
+ }
875
+ folderView.loading = false;
876
+ renderFolderBrowser();
877
+ if ($('folder-browser').open) ($('folder-list').querySelector('.folder-row') ?? $('folder-use')).focus();
878
+ }
879
+
880
+ function renderFolderBrowser() {
881
+ const { listing, loading, error } = folderView;
882
+ const query = $('folder-filter').value.trim().toLowerCase();
883
+ const showHidden = $('folder-hidden').checked;
884
+ $('folder-path').replaceChildren(...(listing?.segments ?? []).map((segment, i, all) => {
885
+ const crumb = button(segment.name, () => browseTo(segment.path), 'folder-crumb');
886
+ if (i === all.length - 1) crumb.setAttribute('aria-current', 'location');
887
+ return crumb;
888
+ }));
889
+ $('folder-roots').replaceChildren(...(listing?.roots ?? []).map((root) => button(root.name, () => browseTo(root.path), 'btn folder-chip')));
890
+ $('folder-up').disabled = loading || !listing?.parent;
891
+ $('folder-home').disabled = loading;
892
+ const note = $('folder-note');
893
+ note.hidden = !listing?.note;
894
+ note.textContent = listing?.note ? `${listing.note} was not found, so this shows the nearest folder that exists.` : '';
895
+ const entries = (listing?.entries ?? []).filter((entry) => (showHidden || !entry.hidden) && (!query || entry.name.toLowerCase().includes(query)));
896
+ $('folder-list').replaceChildren(...entries.map((entry) => {
897
+ const row = button(entry.name, () => browseTo(entry.path), 'folder-row');
898
+ row.classList.toggle('hidden-folder', entry.hidden);
899
+ row.title = entry.path;
900
+ return row;
901
+ }));
902
+ const status = $('folder-status');
903
+ status.textContent = loading ? 'Loading folders…'
904
+ : error ? error
905
+ : !listing ? ''
906
+ : !entries.length ? (listing.entries.length ? 'No folders match.' : 'No folders here.')
907
+ : listing.truncated ? `Showing the first ${listing.entries.length.toLocaleString()} folders.` : '';
908
+ status.classList.toggle('error', Boolean(error));
909
+ $('folder-current').textContent = listing?.path ?? '';
910
+ $('folder-use').disabled = loading || !listing;
911
+ }
912
+
913
+ function folderBrowserClosed() {
914
+ if (!$('folder-browser').open && folderView.opener?.isConnected) folderView.opener.focus();
915
+ }
916
+
917
+ function setCloneParent(dir) {
918
+ $('github-parent').value = dir;
919
+ save(CLONE_PARENT_KEY, dir);
920
+ loadRepos();
921
+ }
922
+
923
+ function useBrowsedFolder() {
924
+ const dir = folderView.listing?.path;
925
+ if (!dir || folderView.loading) return;
926
+ const field = folderView.field;
927
+ $('folder-browser').close();
928
+ if (field === 'clone') setCloneParent(dir);
929
+ else {
930
+ rememberCwd(dir);
931
+ useFolder(dir);
932
+ }
933
+ }
934
+
935
+ function moveInFolders(e) {
936
+ const rows = [...$('folder-list').querySelectorAll('.folder-row')];
937
+ const at = rows.indexOf(document.activeElement);
938
+ const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: rows.length - 1 }[e.key];
939
+ if (at === -1 || next === undefined) return;
940
+ e.preventDefault();
941
+ rows[Math.max(0, Math.min(rows.length - 1, next))].focus();
942
+ }
943
+
944
+ function folderBrowserKeys(e) {
945
+ if (e.key !== 'Backspace' || e.target.matches('input') || !folderView.listing?.parent || folderView.loading) return;
946
+ e.preventDefault();
947
+ browseTo(folderView.listing.parent);
948
+ }
949
+
783
950
  function wsUrl(path) {
784
951
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
785
952
  return `${proto}://${location.host}/api/v1${path}?token=${encodeURIComponent(state.token)}`;
@@ -2251,6 +2418,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
2251
2418
  ({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
2252
2419
  }
2253
2420
  upsertSession(session);
2421
+ rememberCwd(session.cwd);
2254
2422
  closeHistory({ focusOpener: false });
2255
2423
  openPanel(session.id);
2256
2424
  } catch (err) {
@@ -4260,11 +4428,18 @@ class TerminalView {
4260
4428
 
4261
4429
  connect() {
4262
4430
  if (this.disposed || state.remoteRevoked) return;
4431
+ this.inputReady = false;
4432
+ this.inputSnapshot = null;
4433
+ terminalControls.refresh();
4263
4434
  const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
4264
4435
  this.ws = ws;
4265
4436
  ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
4266
- 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)); };
4267
4438
  ws.onclose = (event) => {
4439
+ if (this.ws !== ws) return;
4440
+ this.inputReady = false;
4441
+ this.inputSnapshot = null;
4442
+ terminalControls.refresh();
4268
4443
  if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
4269
4444
  const delay = Math.min(5000, 300 * 2 ** this.retry++);
4270
4445
  setTimeout(() => this.connect(), delay);
@@ -4274,14 +4449,31 @@ class TerminalView {
4274
4449
  onMessage(msg) {
4275
4450
  switch (msg.type) {
4276
4451
  case 'snapshot':
4452
+ this.inputReady = false;
4453
+ this.inputSnapshot = msg;
4454
+ this.inputRun = msg.session.startedAt;
4455
+ terminalControls.refresh();
4277
4456
  this.term.reset();
4278
4457
  this.term.resize(msg.cols, msg.rows);
4279
- this.term.write(msg.data, () => { this.sent = { cols: 0, rows: 0 }; this.refit(); });
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
+ }
4280
4469
  break;
4281
4470
  case 'data':
4282
4471
  this.term.write(msg.data);
4283
4472
  break;
4284
4473
  case 'exit':
4474
+ this.inputReady = false;
4475
+ this.inputSnapshot = null;
4476
+ terminalControls.refresh();
4285
4477
  if (dictation?.id === this.id) stopDictation();
4286
4478
  this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
4287
4479
  break;
@@ -4366,6 +4558,9 @@ class TerminalView {
4366
4558
 
4367
4559
  dispose() {
4368
4560
  this.disposed = true;
4561
+ this.inputReady = false;
4562
+ this.inputSnapshot = null;
4563
+ terminalControls.refresh();
4369
4564
  this.resizeObserver.disconnect();
4370
4565
  this.ws?.close();
4371
4566
  this.term.dispose();
@@ -4375,6 +4570,20 @@ class TerminalView {
4375
4570
 
4376
4571
  // ---- terminal panel -------------------------------------------------------
4377
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
+
4378
4587
  const terminalCopy = new TerminalCopy({
4379
4588
  opener: $('panel-copy'), dialog: $('terminal-copy'),
4380
4589
  getCurrent: () => {
@@ -4412,7 +4621,7 @@ function openPanel(id, { beside = false, restoring = false } = {}) {
4412
4621
  function focusPane(index, { focusTerminal = true } = {}) {
4413
4622
  const id = state.panes[index];
4414
4623
  if (!id) return;
4415
- if (state.activeId !== id) terminalCopy.close();
4624
+ if (state.activeId !== id) { terminalCopy.close(); terminalControls.cancel(); }
4416
4625
  if (dictation && dictation.id !== id) stopDictation();
4417
4626
  state.focusedPane = index;
4418
4627
  state.activeId = id;
@@ -4433,6 +4642,7 @@ function closePane(index, { focusTerminal = true } = {}) {
4433
4642
  }
4434
4643
 
4435
4644
  function closePanel() {
4645
+ terminalControls.cancel();
4436
4646
  terminalCopy.close();
4437
4647
  stopDictation();
4438
4648
  for (const id of state.panes) state.views.get(id)?.unmount();
@@ -4447,7 +4657,8 @@ function closePanel() {
4447
4657
 
4448
4658
  function stageSplit() {
4449
4659
  const box = $('terminal-panel').getBoundingClientRect();
4450
- return splitMode(box.width, box.height);
4660
+ const controls = $('terminal-controls');
4661
+ return splitMode(box.width, box.height - (controls.hidden ? 0 : controls.offsetHeight));
4451
4662
  }
4452
4663
 
4453
4664
  function layoutPanes() {
@@ -4543,6 +4754,7 @@ function restorePanes() {
4543
4754
  }
4544
4755
 
4545
4756
  function updatePanel() {
4757
+ terminalControls.refresh();
4546
4758
  renderVoice();
4547
4759
  const s = state.sessions.get(state.activeId);
4548
4760
  if (!s) return;
@@ -4882,7 +5094,7 @@ function connectEvents() {
4882
5094
  state.restartable = typeof msg.pid === 'number';
4883
5095
  state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
4884
5096
  state.folderOpener = msg.folderOpener || null;
4885
- state.folderPicker = msg.folderPicker || null;
5097
+ state.platform = msg.platform || null;
4886
5098
  state.remoteAccessUI?.setAvailable(msg.remoteAccess);
4887
5099
  renderVersion();
4888
5100
  setConnection('ok', 'Connected to session manager');
@@ -5056,7 +5268,12 @@ bindSplitter($('pane-splitter'), {
5056
5268
  home: () => setSplitRatio(SPLIT_RATIO_MIN),
5057
5269
  endKey: () => setSplitRatio(1 - SPLIT_RATIO_MIN),
5058
5270
  });
5059
- new ResizeObserver(layoutPanes).observe($('terminal-panel'));
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);
5060
5277
  $('panel-voice').addEventListener('click', () => {
5061
5278
  if (dictation) {
5062
5279
  const { id } = dictation;
@@ -5145,10 +5362,8 @@ $('github-views').addEventListener('click', (e) => {
5145
5362
  if (view) switchGitHubView(view);
5146
5363
  });
5147
5364
  $('github-views').addEventListener('keydown', moveGitHubView);
5148
- $('github-parent').addEventListener('change', () => {
5149
- save(CLONE_PARENT_KEY, cloneParent());
5150
- loadRepos();
5151
- });
5365
+ $('github-parent').addEventListener('change', () => setCloneParent(cloneParent()));
5366
+ $('github-parent-pick').addEventListener('click', firstClick(() => chooseFolder('clone')));
5152
5367
  $('history-here').addEventListener('change', renderHistory);
5153
5368
  $('history-form').addEventListener('submit', resumeById);
5154
5369
  $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
@@ -5371,7 +5586,25 @@ $('panel-stop').addEventListener('click', () => {
5371
5586
  else removeSession(s.id);
5372
5587
  });
5373
5588
  $('cwd').value = load(CWD_KEY) || '';
5374
- $('cwd-pick').addEventListener('click', firstClick(pickWorkingFolder));
5589
+ $('cwd-pick').addEventListener('click', firstClick(() => chooseFolder('cwd')));
5590
+ $('cwd').addEventListener('focus', () => showRecentCwds());
5591
+ $('cwd').addEventListener('click', () => { if (!recentView.open) showRecentCwds(); });
5592
+ $('cwd').addEventListener('input', () => showRecentCwds(true));
5593
+ $('cwd').addEventListener('blur', hideRecentCwds);
5594
+ $('cwd').addEventListener('keydown', recentCwdKeys);
5595
+ $('folder-cancel').addEventListener('click', () => $('folder-browser').close());
5596
+ $('folder-browser').addEventListener('click', (e) => { if (e.target === $('folder-browser')) $('folder-browser').close(); });
5597
+ $('folder-browser').addEventListener('close', folderBrowserClosed);
5598
+ $('folder-browser').addEventListener('keydown', folderBrowserKeys);
5599
+ $('folder-list').addEventListener('keydown', moveInFolders);
5600
+ $('folder-up').addEventListener('click', () => browseTo(folderView.listing.parent));
5601
+ $('folder-home').addEventListener('click', () => browseTo('~'));
5602
+ $('folder-filter').addEventListener('input', renderFolderBrowser);
5603
+ $('folder-hidden').addEventListener('change', (e) => {
5604
+ save(HIDDEN_FOLDERS_KEY, e.target.checked ? '1' : null);
5605
+ renderFolderBrowser();
5606
+ });
5607
+ $('folder-use').addEventListener('click', useBrowsedFolder);
5375
5608
  $('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
5376
5609
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
5377
5610
  try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
@@ -5388,8 +5621,13 @@ setInterval(() => { if (dockShows('github') && state.github) renderGitHub(); },
5388
5621
  // The terminal panel and the dock sit below the top bar; publish where it ends so they never cover its controls.
5389
5622
  const topbar = document.querySelector('.topbar');
5390
5623
  const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${Math.max(0, Math.round(topbar.getBoundingClientRect().bottom))}px`);
5391
- new ResizeObserver(publishTopbarHeight).observe(topbar);
5392
- addEventListener('scroll', publishTopbarHeight, { passive: true });
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 });
5393
5631
  publishTopbarHeight();
5394
5632
  applyDockLayout();
5395
5633
 
package/web/folders.js ADDED
@@ -0,0 +1,17 @@
1
+ export const RECENT_FOLDERS_MAX = 5;
2
+
3
+ export function readRecentFolders(text) {
4
+ let list;
5
+ try { list = JSON.parse(text); } catch { return []; }
6
+ return Array.isArray(list) ? list.filter((dir) => typeof dir === 'string' && dir.trim()).slice(0, RECENT_FOLDERS_MAX) : [];
7
+ }
8
+
9
+ export function rememberFolder(list, dir, { caseless = false } = {}) {
10
+ const key = (value) => (caseless ? value.toLowerCase() : value);
11
+ return [dir, ...list.filter((known) => key(known) !== key(dir))].slice(0, RECENT_FOLDERS_MAX);
12
+ }
13
+
14
+ export function matchFolders(list, query) {
15
+ const wanted = query.trim().toLowerCase();
16
+ return wanted ? list.filter((dir) => dir.toLowerCase().includes(wanted)) : list;
17
+ }
package/web/index.html CHANGED
@@ -69,7 +69,8 @@
69
69
  <div class="cwd">
70
70
  <label for="cwd">Working folder</label>
71
71
  <div class="cwd-field">
72
- <input id="cwd" type="text" spellcheck="false" autocomplete="off" placeholder="Home folder">
72
+ <input id="cwd" type="text" spellcheck="false" autocomplete="off" placeholder="Home folder" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="cwd-recent">
73
+ <ul id="cwd-recent" class="cwd-recent" role="listbox" aria-label="Recent working folders" hidden></ul>
73
74
  <div class="cwd-tools">
74
75
  <button id="cwd-pick" class="cwd-tool" type="button" aria-label="Choose a folder…" title="Connect to the session manager to choose folders" disabled>
75
76
  <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
@@ -168,6 +169,14 @@
168
169
  <div class="terminal-host"></div>
169
170
  </section>
170
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>
171
180
  <div id="voice-preview" class="voice-preview" hidden aria-live="polite"></div>
172
181
  </div>
173
182
 
@@ -234,7 +243,17 @@
234
243
  <div id="github-pulls" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-pulls" hidden></div>
235
244
  <div id="github-clone-into" class="history-form github-form">
236
245
  <label for="github-parent">Clone into</label>
237
- <input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
246
+ <div class="cwd-field single">
247
+ <input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
248
+ <div class="cwd-tools">
249
+ <button id="github-parent-pick" class="cwd-tool" type="button" aria-label="Choose a folder…" title="Connect to the session manager to choose folders" disabled>
250
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
251
+ <path d="M3.5 9V6.5A1.5 1.5 0 0 1 5 5h4l2 2h7.5A1.5 1.5 0 0 1 20 8.5V10"/>
252
+ <path d="M3.5 10.5H21l-2.5 8H5.5z"/>
253
+ </svg>
254
+ </button>
255
+ </div>
256
+ </div>
238
257
  </div>
239
258
  </section>
240
259
  <section id="notes" class="dock-panel notes-panel" role="tabpanel" aria-labelledby="notes-title" hidden>
@@ -402,6 +421,35 @@
402
421
  </form>
403
422
  </dialog>
404
423
 
424
+ <dialog id="folder-browser" class="models folder-browser" aria-labelledby="folder-title">
425
+ <div class="models-bar">
426
+ <div class="models-heading">
427
+ <h2 id="folder-title">Choose working folder</h2>
428
+ <p class="sub">Folders on the computer running Agent Guild</p>
429
+ </div>
430
+ <button id="folder-cancel" class="btn" type="button">Cancel</button>
431
+ </div>
432
+ <nav id="folder-path" class="history-tools folder-path" aria-label="Current folder"></nav>
433
+ <div class="history-tools folder-places">
434
+ <button id="folder-up" class="btn" type="button">Up</button>
435
+ <button id="folder-home" class="btn folder-chip" type="button">Home</button>
436
+ <div id="folder-roots" class="folder-roots"></div>
437
+ </div>
438
+ <div class="history-tools">
439
+ <input id="folder-filter" type="search" placeholder="Filter folders" aria-label="Filter folders" spellcheck="false" autocomplete="off">
440
+ <label class="history-here"><input id="folder-hidden" type="checkbox"><span>Show hidden</span></label>
441
+ </div>
442
+ <div class="models-body">
443
+ <p id="folder-note" class="models-note" hidden></p>
444
+ <div id="folder-list" class="folder-list" role="group" aria-label="Folders"></div>
445
+ <p id="folder-status" class="models-note" aria-live="polite"></p>
446
+ </div>
447
+ <div class="history-form folder-footer">
448
+ <span id="folder-current" class="folder-current"></span>
449
+ <button id="folder-use" class="btn primary" type="button">Use this folder</button>
450
+ </div>
451
+ </dialog>
452
+
405
453
  <div id="tip" class="tip" aria-hidden="true" hidden><strong id="tip-title"></strong><span id="tip-source"></span><p id="tip-text"></p></div>
406
454
 
407
455
  <div id="toast" class="toast" role="alert" hidden></div>
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 0; border-radius: 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
- .models-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
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 0; border-radius: 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; }
@@ -998,3 +1023,37 @@ a.btn.github-out::after {
998
1023
  .multiplexer-row .copies { margin-top: .4rem; }
999
1024
  .multiplexer-row .copy-head { flex-wrap: wrap; }
1000
1025
  .multiplexer-row .copy-name { flex: 1 0 100%; }
1026
+
1027
+ .cwd-field.single input { padding-right: 40px; }
1028
+ @media (pointer: coarse) { .cwd-field.single input { min-height: 46px; padding-right: 50px; } }
1029
+ .folder-browser { width: min(640px, calc(100vw - 32px)); height: min(720px, calc(100dvh - 48px)); }
1030
+ @media (max-width: 640px) { .folder-browser { width: 100vw; height: 100dvh; } }
1031
+ .folder-path { gap: 2px 0; font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; }
1032
+ .folder-crumb { min-height: 32px; padding: 2px 6px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font: inherit; cursor: pointer; overflow-wrap: anywhere; text-align: left; }
1033
+ .folder-crumb:not(:first-child)::before { content: "›"; margin-right: 6px; color: var(--muted); }
1034
+ .folder-crumb:hover, .folder-crumb[aria-current] { color: var(--text); }
1035
+ .folder-crumb:focus-visible, .folder-row:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
1036
+ .folder-places, .folder-roots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1037
+ .folder-places .btn { min-height: 36px; }
1038
+ .folder-list { display: flex; flex-direction: column; }
1039
+ .folder-row {
1040
+ display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; border: 0; border-bottom: 1px solid var(--border);
1041
+ background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere;
1042
+ }
1043
+ .folder-row::before { content: ""; flex: none; width: 18px; height: 14px; border: 1.6px solid var(--muted); border-radius: 2px 4px 3px 3px; }
1044
+ .folder-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
1045
+ .folder-row.hidden-folder { color: var(--muted); }
1046
+ #folder-status.error { color: var(--danger); }
1047
+ .folder-footer { justify-content: space-between; }
1048
+ .folder-current { flex: 1; min-width: 0; font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.8rem; overflow-wrap: anywhere; }
1049
+ .folder-footer .btn { min-height: 42px; }
1050
+ .cwd-recent {
1051
+ position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 5; margin: 0; padding: 4px; list-style: none;
1052
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
1053
+ }
1054
+ .cwd-recent-option {
1055
+ display: flex; align-items: center; min-height: 40px; padding: 6px 10px; border-radius: max(3px, calc(var(--radius) - 3px));
1056
+ color: var(--text); font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; overflow-wrap: anywhere; cursor: pointer;
1057
+ }
1058
+ .cwd-recent-option:hover, .cwd-recent-option[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 14%, transparent); }
1059
+ @media (pointer: coarse) { .cwd-recent-option { min-height: 46px; } }
@@ -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
+ }
@@ -1,116 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { spawn } from 'node:child_process';
5
-
6
- const TITLE = 'Choose a working folder';
7
- const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
8
- const isFile = (file) => { try { return fs.statSync(file).isFile(); } catch { return false; } };
9
- const isFolder = (dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } };
10
-
11
- const APPLESCRIPT = [
12
- 'on run argv',
13
- 'activate',
14
- 'try',
15
- `return POSIX path of (choose folder with prompt "${TITLE}" default location (POSIX file (item 1 of argv)))`,
16
- 'on error number -128',
17
- 'return ""',
18
- 'end try',
19
- 'end run',
20
- ];
21
-
22
- let windowsScript;
23
- function encodedWindowsScript() {
24
- windowsScript ??= Buffer.from(fs.readFileSync(new URL('./folder-picker.ps1', import.meta.url), 'utf8'), 'utf16le').toString('base64');
25
- return windowsScript;
26
- }
27
-
28
- export function folderPickTarget({ platform = process.platform, env = process.env, release = os.release(), fileExists = isFile } = {}) {
29
- const onPath = (name) => (env.PATH || '').split(':').filter((dir) => path.posix.isAbsolute(dir))
30
- .map((dir) => path.posix.join(dir, name)).find(fileExists);
31
- let command, kind, translate, reason;
32
- if (platform === 'win32') {
33
- kind = 'windows';
34
- command = path.win32.join(env.SystemRoot || env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
35
- } else if (platform === 'darwin') {
36
- kind = 'mac';
37
- command = '/usr/bin/osascript';
38
- } else if (platform === 'linux' && (env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
39
- kind = 'windows';
40
- translate = onPath('wslpath');
41
- command = translate && onPath('powershell.exe');
42
- reason = 'Windows PowerShell is unavailable. Enable Windows interoperability in WSL to choose folders.';
43
- } else if (platform === 'linux') {
44
- if (env.DISPLAY || env.WAYLAND_DISPLAY) {
45
- command = onPath('zenity');
46
- kind = command ? 'zenity' : 'kdialog';
47
- command ||= onPath('kdialog');
48
- reason = 'Install zenity or kdialog to choose folders.';
49
- } else reason = 'Choosing folders requires a desktop session on the computer running Agent Guild.';
50
- } else reason = 'Choosing folders is unavailable on this operating system.';
51
- const available = Boolean(command && fileExists(command));
52
- return { command, kind, translate, available, reason: available ? null : reason || 'The folder dialog is unavailable on the computer running Agent Guild.' };
53
- }
54
-
55
- export function createFolderPicker({ resolveCwd, spawnImpl = spawn, env = process.env, ...targetOptions }) {
56
- let pending = false;
57
-
58
- const run = (command, args, { signal, env: childEnv = env } = {}) => new Promise((resolve, reject) => {
59
- let child, out = '';
60
- try {
61
- child = spawnImpl(command, args, { env: childEnv, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
62
- } catch (error) { return reject(error); }
63
- const stop = () => child.kill();
64
- if (signal?.aborted) stop();
65
- else signal?.addEventListener('abort', stop, { once: true });
66
- child.stdout.setEncoding('utf8').on('data', (chunk) => { out += chunk; });
67
- child.once('error', reject);
68
- child.once('close', (code) => {
69
- signal?.removeEventListener('abort', stop);
70
- resolve({ code, out: out.replace(/[\r\n]+$/, '') });
71
- });
72
- });
73
-
74
- function dialog(target, start, signal) {
75
- if (target.kind === 'windows') {
76
- const vars = { AGENT_GUILD_PICK_START: start, AGENT_GUILD_PICK_TITLE: TITLE };
77
- if (target.translate) vars.WSLENV = [env.WSLENV, 'AGENT_GUILD_PICK_START/p', 'AGENT_GUILD_PICK_TITLE'].filter(Boolean).join(':');
78
- return run(target.command, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedWindowsScript()], { signal, env: { ...env, ...vars } });
79
- }
80
- if (target.kind === 'mac') return run(target.command, [...APPLESCRIPT.flatMap((line) => ['-e', line]), start], { signal });
81
- if (target.kind === 'zenity') return run(target.command, ['--file-selection', '--directory', `--title=${TITLE}`, `--filename=${path.join(start, path.sep)}`], { signal });
82
- return run(target.command, ['--title', TITLE, '--getexistingdirectory', start], { signal });
83
- }
84
-
85
- return {
86
- describe() {
87
- const { available, reason } = folderPickTarget({ env, ...targetOptions });
88
- return { available, reason };
89
- },
90
- async pick(cwd, { signal } = {}) {
91
- if (cwd !== undefined && (typeof cwd !== 'string' || /[\0\r\n]/.test(cwd))) {
92
- throw failure(400, 'bad_cwd', 'The working folder must be a directory path.');
93
- }
94
- if (pending) throw failure(409, 'folder_pick_busy', 'A folder dialog is already open on the computer running Agent Guild.');
95
- const target = folderPickTarget({ env, ...targetOptions });
96
- if (!target.available) throw failure(409, 'folder_pick_unavailable', target.reason);
97
- let start;
98
- try { start = resolveCwd(cwd); } catch { start = os.homedir(); }
99
- pending = true;
100
- try {
101
- const broken = () => failure(409, 'folder_pick_failed', 'Could not show the folder dialog. Check that a desktop session is available on the computer running Agent Guild.');
102
- let result = await dialog(target, start, signal).catch(() => { throw broken(); });
103
- const cancelled = result.code === 0 || (result.code === 1 && target.kind !== 'windows' && target.kind !== 'mac');
104
- if (signal?.aborted || (!result.out && cancelled)) return null;
105
- if (result.code !== 0) throw broken();
106
- if (target.translate) {
107
- result = await run(target.translate, ['-u', result.out], { signal }).catch(() => ({ code: 1 }));
108
- if (result.code !== 0) throw failure(409, 'folder_pick_failed', 'The chosen folder cannot be reached from WSL.');
109
- }
110
- const dir = path.resolve(result.out);
111
- if (!isFolder(dir)) throw failure(409, 'folder_pick_failed', 'The chosen folder cannot be used as a working folder.');
112
- return dir;
113
- } finally { pending = false; }
114
- },
115
- };
116
- }
@@ -1,84 +0,0 @@
1
- $ErrorActionPreference = 'Stop'
2
- [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
3
- Add-Type -AssemblyName System.Windows.Forms
4
- Add-Type -ReferencedAssemblies System.Windows.Forms -TypeDefinition @'
5
- using System;
6
- using System.Runtime.InteropServices;
7
- using System.Windows.Forms;
8
- public static class GuildFolderPicker {
9
- [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class FileOpenDialog {}
10
- [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
11
- interface IFileDialog {
12
- [PreserveSig] int Show(IntPtr owner);
13
- void SetFileTypes(uint count, IntPtr specs);
14
- void SetFileTypeIndex(uint index);
15
- void GetFileTypeIndex(out uint index);
16
- void Advise(IntPtr events, out uint cookie);
17
- void Unadvise(uint cookie);
18
- void SetOptions(uint options);
19
- void GetOptions(out uint options);
20
- void SetDefaultFolder(IShellItem item);
21
- void SetFolder(IShellItem item);
22
- void GetFolder(out IShellItem item);
23
- void GetCurrentSelection(out IShellItem item);
24
- void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
25
- void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
26
- void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
27
- void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
28
- void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
29
- void GetResult(out IShellItem item);
30
- }
31
- [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
32
- interface IShellItem {
33
- void BindToHandler(IntPtr context, ref Guid handler, ref Guid iid, out IntPtr result);
34
- void GetParent(out IShellItem parent);
35
- void GetDisplayName(uint form, [MarshalAs(UnmanagedType.LPWStr)] out string name);
36
- }
37
- [StructLayout(LayoutKind.Sequential)] struct RECT { public int L, T, R, B; }
38
- [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
39
- static extern void SHCreateItemFromParsingName(string path, IntPtr context, ref Guid iid, out IShellItem item);
40
- [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr window, out RECT rect);
41
- [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
42
- [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, IntPtr process);
43
- [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
44
- [DllImport("user32.dll")] static extern bool AttachThreadInput(uint from, uint to, bool attach);
45
- [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr window);
46
-
47
- public static string Pick(string start, string title) {
48
- var dialog = (IFileDialog)new FileOpenDialog();
49
- uint options;
50
- dialog.GetOptions(out options);
51
- dialog.SetOptions(options | 0x20 | 0x40 | 0x800);
52
- dialog.SetTitle(title);
53
- if (!string.IsNullOrEmpty(start)) {
54
- try {
55
- IShellItem folder;
56
- Guid iid = typeof(IShellItem).GUID;
57
- SHCreateItemFromParsingName(start, IntPtr.Zero, ref iid, out folder);
58
- dialog.SetFolder(folder);
59
- } catch {}
60
- }
61
- // A dialog from a background process opens behind the browser. An invisible owner that takes
62
- // the foreground brings it to the front, just inside the window that asked.
63
- using (var owner = new Form { TopMost = true, ShowInTaskbar = false, FormBorderStyle = FormBorderStyle.None, Opacity = 0, StartPosition = FormStartPosition.Manual }) {
64
- IntPtr asking = GetForegroundWindow();
65
- RECT r;
66
- if (GetWindowRect(asking, out r)) owner.SetBounds(r.L + 48, r.T + 48, 1, 1);
67
- else owner.StartPosition = FormStartPosition.CenterScreen;
68
- owner.Show();
69
- uint front = GetWindowThreadProcessId(asking, IntPtr.Zero), self = GetCurrentThreadId();
70
- AttachThreadInput(self, front, true);
71
- SetForegroundWindow(owner.Handle);
72
- AttachThreadInput(self, front, false);
73
- if (dialog.Show(owner.Handle) != 0) return null;
74
- }
75
- IShellItem picked;
76
- dialog.GetResult(out picked);
77
- string path;
78
- picked.GetDisplayName(0x80058000, out path);
79
- return path;
80
- }
81
- }
82
- '@
83
- $picked = [GuildFolderPicker]::Pick($env:AGENT_GUILD_PICK_START, $env:AGENT_GUILD_PICK_TITLE)
84
- if ($picked) { [Console]::Out.Write($picked) }