@oddessentials/agent-guild 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
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
@@ -7,6 +7,7 @@ import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, cla
7
7
  import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
8
8
  import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
9
9
  import { createRemoteAccessUI } from './remote-access.js';
10
+ import { matchFolders, readRecentFolders, rememberFolder } from './folders.js';
10
11
 
11
12
  const TOKEN_KEY = 'agentGuild.token';
12
13
  const CWD_KEY = 'agentGuild.cwd';
@@ -19,6 +20,8 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
19
20
  const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
20
21
  const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
21
22
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
23
+ const HIDDEN_FOLDERS_KEY = 'agentGuild.showHiddenFolders';
24
+ const RECENT_CWDS_KEY = 'agentGuild.recentCwds';
22
25
  const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
23
26
  const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
24
27
  const GITHUB_VIEW_KEY = 'agentGuild.githubView';
@@ -62,8 +65,8 @@ const state = {
62
65
  connected: false,
63
66
  folderOpener: null,
64
67
  folderOpening: false,
65
- folderPicker: null,
66
- folderPicking: false,
68
+ /** The manager's operating system, from `hello`. */
69
+ platform: null,
67
70
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
68
71
  upgrade: null,
69
72
  /** The running manager's version and pid, from `hello`. */
@@ -737,11 +740,9 @@ function renderFolderTools() {
737
740
  : !feature?.available ? feature?.reason || text.missing
738
741
  : label;
739
742
  };
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
- });
743
+ for (const id of ['cwd-pick', 'github-parent-pick']) {
744
+ paint(id, { available: true }, false, 'Choose a folder…', { offline: 'Connect to the session manager to choose folders' });
745
+ }
745
746
  paint('cwd-open', state.folderOpener, state.folderOpening, `Open working folder in ${state.folderOpener?.label || 'file manager'}`, {
746
747
  offline: 'Connect to the session manager to open folders',
747
748
  busy: 'Opening working folder…',
@@ -749,22 +750,6 @@ function renderFolderTools() {
749
750
  });
750
751
  }
751
752
 
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
753
  async function openWorkingFolder() {
769
754
  if (!state.connected || !state.folderOpener?.available || state.folderOpening) return;
770
755
  state.folderOpening = true;
@@ -780,6 +765,186 @@ async function openWorkingFolder() {
780
765
  }
781
766
  }
782
767
 
768
+ // ---- recent working folders -----------------------------------------------
769
+
770
+ const recentView = { open: false, typed: false, active: -1 };
771
+
772
+ function recentCwds() {
773
+ return readRecentFolders(load(RECENT_CWDS_KEY));
774
+ }
775
+
776
+ function rememberCwd(dir) {
777
+ if (dir) save(RECENT_CWDS_KEY, JSON.stringify(rememberFolder(recentCwds(), dir, { caseless: state.platform === 'win32' })));
778
+ }
779
+
780
+ function renderRecentCwds() {
781
+ const input = $('cwd'), list = $('cwd-recent');
782
+ const shown = recentView.open ? matchFolders(recentCwds(), recentView.typed ? input.value : '') : [];
783
+ recentView.active = Math.min(recentView.active, shown.length - 1);
784
+ list.replaceChildren(...shown.map((dir, i) => {
785
+ const option = el('li', 'cwd-recent-option', dir);
786
+ option.id = `cwd-recent-${i}`;
787
+ option.setAttribute('role', 'option');
788
+ option.setAttribute('aria-selected', String(i === recentView.active));
789
+ option.addEventListener('pointerdown', (e) => e.preventDefault());
790
+ option.addEventListener('click', () => pickRecentCwd(dir));
791
+ return option;
792
+ }));
793
+ list.hidden = !shown.length;
794
+ input.setAttribute('aria-expanded', String(!list.hidden));
795
+ if (recentView.active >= 0) input.setAttribute('aria-activedescendant', `cwd-recent-${recentView.active}`);
796
+ else input.removeAttribute('aria-activedescendant');
797
+ }
798
+
799
+ function showRecentCwds(typed = false) {
800
+ Object.assign(recentView, { open: true, typed, active: -1 });
801
+ renderRecentCwds();
802
+ }
803
+
804
+ function hideRecentCwds() {
805
+ Object.assign(recentView, { open: false, active: -1 });
806
+ renderRecentCwds();
807
+ }
808
+
809
+ function pickRecentCwd(dir) {
810
+ hideRecentCwds();
811
+ useFolder(dir);
812
+ }
813
+
814
+ function recentCwdKeys(e) {
815
+ const options = $('cwd-recent').hidden ? [] : [...$('cwd-recent').children];
816
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
817
+ e.preventDefault();
818
+ if (!recentView.open) showRecentCwds();
819
+ const count = $('cwd-recent').children.length;
820
+ if (!count) return;
821
+ recentView.active = e.key === 'ArrowDown' ? (recentView.active + 1) % count : recentView.active <= 0 ? count - 1 : recentView.active - 1;
822
+ renderRecentCwds();
823
+ $(`cwd-recent-${recentView.active}`).scrollIntoView?.({ block: 'nearest' });
824
+ } else if (e.key === 'Enter' && recentView.active >= 0 && options[recentView.active]) {
825
+ e.preventDefault();
826
+ pickRecentCwd(options[recentView.active].textContent);
827
+ } else if (e.key === 'Escape' && options.length) {
828
+ e.preventDefault();
829
+ hideRecentCwds();
830
+ }
831
+ }
832
+
833
+ // ---- folder browser -------------------------------------------------------
834
+
835
+ const FOLDER_FIELDS = {
836
+ cwd: { title: 'Choose working folder', input: 'cwd' },
837
+ clone: { title: 'Choose clone folder', input: 'github-parent' },
838
+ };
839
+ const folderView = { field: null, listing: null, loading: false, error: null, request: 0, opener: null };
840
+
841
+ function chooseFolder(field) {
842
+ if (!state.connected) return;
843
+ const dialog = $('folder-browser');
844
+ Object.assign(folderView, { field, listing: null, loading: false, error: null });
845
+ $('folder-title').textContent = FOLDER_FIELDS[field].title;
846
+ $('folder-filter').value = '';
847
+ $('folder-hidden').checked = load(HIDDEN_FOLDERS_KEY) === '1';
848
+ if (!dialog.open) {
849
+ folderView.opener = document.activeElement;
850
+ dialog.showModal();
851
+ }
852
+ browseTo($(FOLDER_FIELDS[field].input).value.trim());
853
+ }
854
+
855
+ async function browseTo(dir) {
856
+ const request = ++folderView.request;
857
+ folderView.loading = true;
858
+ folderView.error = null;
859
+ renderFolderBrowser();
860
+ try {
861
+ const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}`);
862
+ if (request !== folderView.request) return;
863
+ folderView.listing = listing;
864
+ $('folder-filter').value = '';
865
+ } catch (err) {
866
+ if (request !== folderView.request) return;
867
+ if (err instanceof AuthError) {
868
+ $('folder-browser').close();
869
+ return showAuth(err.message);
870
+ }
871
+ folderView.error = err.message || 'Could not list this folder.';
872
+ }
873
+ folderView.loading = false;
874
+ renderFolderBrowser();
875
+ if ($('folder-browser').open) ($('folder-list').querySelector('.folder-row') ?? $('folder-use')).focus();
876
+ }
877
+
878
+ function renderFolderBrowser() {
879
+ const { listing, loading, error } = folderView;
880
+ const query = $('folder-filter').value.trim().toLowerCase();
881
+ const showHidden = $('folder-hidden').checked;
882
+ $('folder-path').replaceChildren(...(listing?.segments ?? []).map((segment, i, all) => {
883
+ const crumb = button(segment.name, () => browseTo(segment.path), 'folder-crumb');
884
+ if (i === all.length - 1) crumb.setAttribute('aria-current', 'location');
885
+ return crumb;
886
+ }));
887
+ $('folder-roots').replaceChildren(...(listing?.roots ?? []).map((root) => button(root.name, () => browseTo(root.path), 'btn folder-chip')));
888
+ $('folder-up').disabled = loading || !listing?.parent;
889
+ $('folder-home').disabled = loading;
890
+ const note = $('folder-note');
891
+ note.hidden = !listing?.note;
892
+ note.textContent = listing?.note ? `${listing.note} was not found, so this shows the nearest folder that exists.` : '';
893
+ const entries = (listing?.entries ?? []).filter((entry) => (showHidden || !entry.hidden) && (!query || entry.name.toLowerCase().includes(query)));
894
+ $('folder-list').replaceChildren(...entries.map((entry) => {
895
+ const row = button(entry.name, () => browseTo(entry.path), 'folder-row');
896
+ row.classList.toggle('hidden-folder', entry.hidden);
897
+ row.title = entry.path;
898
+ return row;
899
+ }));
900
+ const status = $('folder-status');
901
+ status.textContent = loading ? 'Loading folders…'
902
+ : error ? error
903
+ : !listing ? ''
904
+ : !entries.length ? (listing.entries.length ? 'No folders match.' : 'No folders here.')
905
+ : listing.truncated ? `Showing the first ${listing.entries.length.toLocaleString()} folders.` : '';
906
+ status.classList.toggle('error', Boolean(error));
907
+ $('folder-current').textContent = listing?.path ?? '';
908
+ $('folder-use').disabled = loading || !listing;
909
+ }
910
+
911
+ function folderBrowserClosed() {
912
+ if (!$('folder-browser').open && folderView.opener?.isConnected) folderView.opener.focus();
913
+ }
914
+
915
+ function setCloneParent(dir) {
916
+ $('github-parent').value = dir;
917
+ save(CLONE_PARENT_KEY, dir);
918
+ loadRepos();
919
+ }
920
+
921
+ function useBrowsedFolder() {
922
+ const dir = folderView.listing?.path;
923
+ if (!dir || folderView.loading) return;
924
+ const field = folderView.field;
925
+ $('folder-browser').close();
926
+ if (field === 'clone') setCloneParent(dir);
927
+ else {
928
+ rememberCwd(dir);
929
+ useFolder(dir);
930
+ }
931
+ }
932
+
933
+ function moveInFolders(e) {
934
+ const rows = [...$('folder-list').querySelectorAll('.folder-row')];
935
+ const at = rows.indexOf(document.activeElement);
936
+ const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: rows.length - 1 }[e.key];
937
+ if (at === -1 || next === undefined) return;
938
+ e.preventDefault();
939
+ rows[Math.max(0, Math.min(rows.length - 1, next))].focus();
940
+ }
941
+
942
+ function folderBrowserKeys(e) {
943
+ if (e.key !== 'Backspace' || e.target.matches('input') || !folderView.listing?.parent || folderView.loading) return;
944
+ e.preventDefault();
945
+ browseTo(folderView.listing.parent);
946
+ }
947
+
783
948
  function wsUrl(path) {
784
949
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
785
950
  return `${proto}://${location.host}/api/v1${path}?token=${encodeURIComponent(state.token)}`;
@@ -2251,6 +2416,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
2251
2416
  ({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
2252
2417
  }
2253
2418
  upsertSession(session);
2419
+ rememberCwd(session.cwd);
2254
2420
  closeHistory({ focusOpener: false });
2255
2421
  openPanel(session.id);
2256
2422
  } catch (err) {
@@ -4882,7 +5048,7 @@ function connectEvents() {
4882
5048
  state.restartable = typeof msg.pid === 'number';
4883
5049
  state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
4884
5050
  state.folderOpener = msg.folderOpener || null;
4885
- state.folderPicker = msg.folderPicker || null;
5051
+ state.platform = msg.platform || null;
4886
5052
  state.remoteAccessUI?.setAvailable(msg.remoteAccess);
4887
5053
  renderVersion();
4888
5054
  setConnection('ok', 'Connected to session manager');
@@ -5145,10 +5311,8 @@ $('github-views').addEventListener('click', (e) => {
5145
5311
  if (view) switchGitHubView(view);
5146
5312
  });
5147
5313
  $('github-views').addEventListener('keydown', moveGitHubView);
5148
- $('github-parent').addEventListener('change', () => {
5149
- save(CLONE_PARENT_KEY, cloneParent());
5150
- loadRepos();
5151
- });
5314
+ $('github-parent').addEventListener('change', () => setCloneParent(cloneParent()));
5315
+ $('github-parent-pick').addEventListener('click', firstClick(() => chooseFolder('clone')));
5152
5316
  $('history-here').addEventListener('change', renderHistory);
5153
5317
  $('history-form').addEventListener('submit', resumeById);
5154
5318
  $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
@@ -5371,7 +5535,25 @@ $('panel-stop').addEventListener('click', () => {
5371
5535
  else removeSession(s.id);
5372
5536
  });
5373
5537
  $('cwd').value = load(CWD_KEY) || '';
5374
- $('cwd-pick').addEventListener('click', firstClick(pickWorkingFolder));
5538
+ $('cwd-pick').addEventListener('click', firstClick(() => chooseFolder('cwd')));
5539
+ $('cwd').addEventListener('focus', () => showRecentCwds());
5540
+ $('cwd').addEventListener('click', () => { if (!recentView.open) showRecentCwds(); });
5541
+ $('cwd').addEventListener('input', () => showRecentCwds(true));
5542
+ $('cwd').addEventListener('blur', hideRecentCwds);
5543
+ $('cwd').addEventListener('keydown', recentCwdKeys);
5544
+ $('folder-cancel').addEventListener('click', () => $('folder-browser').close());
5545
+ $('folder-browser').addEventListener('click', (e) => { if (e.target === $('folder-browser')) $('folder-browser').close(); });
5546
+ $('folder-browser').addEventListener('close', folderBrowserClosed);
5547
+ $('folder-browser').addEventListener('keydown', folderBrowserKeys);
5548
+ $('folder-list').addEventListener('keydown', moveInFolders);
5549
+ $('folder-up').addEventListener('click', () => browseTo(folderView.listing.parent));
5550
+ $('folder-home').addEventListener('click', () => browseTo('~'));
5551
+ $('folder-filter').addEventListener('input', renderFolderBrowser);
5552
+ $('folder-hidden').addEventListener('change', (e) => {
5553
+ save(HIDDEN_FOLDERS_KEY, e.target.checked ? '1' : null);
5554
+ renderFolderBrowser();
5555
+ });
5556
+ $('folder-use').addEventListener('click', useBrowsedFolder);
5375
5557
  $('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
5376
5558
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
5377
5559
  try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
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">
@@ -234,7 +235,17 @@
234
235
  <div id="github-pulls" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-pulls" hidden></div>
235
236
  <div id="github-clone-into" class="history-form github-form">
236
237
  <label for="github-parent">Clone into</label>
237
- <input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
238
+ <div class="cwd-field single">
239
+ <input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
240
+ <div class="cwd-tools">
241
+ <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>
242
+ <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">
243
+ <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"/>
244
+ <path d="M3.5 10.5H21l-2.5 8H5.5z"/>
245
+ </svg>
246
+ </button>
247
+ </div>
248
+ </div>
238
249
  </div>
239
250
  </section>
240
251
  <section id="notes" class="dock-panel notes-panel" role="tabpanel" aria-labelledby="notes-title" hidden>
@@ -402,6 +413,35 @@
402
413
  </form>
403
414
  </dialog>
404
415
 
416
+ <dialog id="folder-browser" class="models folder-browser" aria-labelledby="folder-title">
417
+ <div class="models-bar">
418
+ <div class="models-heading">
419
+ <h2 id="folder-title">Choose working folder</h2>
420
+ <p class="sub">Folders on the computer running Agent Guild</p>
421
+ </div>
422
+ <button id="folder-cancel" class="btn" type="button">Cancel</button>
423
+ </div>
424
+ <nav id="folder-path" class="history-tools folder-path" aria-label="Current folder"></nav>
425
+ <div class="history-tools folder-places">
426
+ <button id="folder-up" class="btn" type="button">Up</button>
427
+ <button id="folder-home" class="btn folder-chip" type="button">Home</button>
428
+ <div id="folder-roots" class="folder-roots"></div>
429
+ </div>
430
+ <div class="history-tools">
431
+ <input id="folder-filter" type="search" placeholder="Filter folders" aria-label="Filter folders" spellcheck="false" autocomplete="off">
432
+ <label class="history-here"><input id="folder-hidden" type="checkbox"><span>Show hidden</span></label>
433
+ </div>
434
+ <div class="models-body">
435
+ <p id="folder-note" class="models-note" hidden></p>
436
+ <div id="folder-list" class="folder-list" role="group" aria-label="Folders"></div>
437
+ <p id="folder-status" class="models-note" aria-live="polite"></p>
438
+ </div>
439
+ <div class="history-form folder-footer">
440
+ <span id="folder-current" class="folder-current"></span>
441
+ <button id="folder-use" class="btn primary" type="button">Use this folder</button>
442
+ </div>
443
+ </dialog>
444
+
405
445
  <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
446
 
407
447
  <div id="toast" class="toast" role="alert" hidden></div>
package/web/styles.css CHANGED
@@ -998,3 +998,37 @@ a.btn.github-out::after {
998
998
  .multiplexer-row .copies { margin-top: .4rem; }
999
999
  .multiplexer-row .copy-head { flex-wrap: wrap; }
1000
1000
  .multiplexer-row .copy-name { flex: 1 0 100%; }
1001
+
1002
+ .cwd-field.single input { padding-right: 40px; }
1003
+ @media (pointer: coarse) { .cwd-field.single input { min-height: 46px; padding-right: 50px; } }
1004
+ .folder-browser { width: min(640px, calc(100vw - 32px)); height: min(720px, calc(100dvh - 48px)); }
1005
+ @media (max-width: 640px) { .folder-browser { width: 100vw; height: 100dvh; } }
1006
+ .folder-path { gap: 2px 0; font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; }
1007
+ .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; }
1008
+ .folder-crumb:not(:first-child)::before { content: "›"; margin-right: 6px; color: var(--muted); }
1009
+ .folder-crumb:hover, .folder-crumb[aria-current] { color: var(--text); }
1010
+ .folder-crumb:focus-visible, .folder-row:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
1011
+ .folder-places, .folder-roots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1012
+ .folder-places .btn { min-height: 36px; }
1013
+ .folder-list { display: flex; flex-direction: column; }
1014
+ .folder-row {
1015
+ display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; border: 0; border-bottom: 1px solid var(--border);
1016
+ background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere;
1017
+ }
1018
+ .folder-row::before { content: ""; flex: none; width: 18px; height: 14px; border: 1.6px solid var(--muted); border-radius: 2px 4px 3px 3px; }
1019
+ .folder-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
1020
+ .folder-row.hidden-folder { color: var(--muted); }
1021
+ #folder-status.error { color: var(--danger); }
1022
+ .folder-footer { justify-content: space-between; }
1023
+ .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; }
1024
+ .folder-footer .btn { min-height: 42px; }
1025
+ .cwd-recent {
1026
+ position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 5; margin: 0; padding: 4px; list-style: none;
1027
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
1028
+ }
1029
+ .cwd-recent-option {
1030
+ display: flex; align-items: center; min-height: 40px; padding: 6px 10px; border-radius: max(3px, calc(var(--radius) - 3px));
1031
+ color: var(--text); font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; overflow-wrap: anywhere; cursor: pointer;
1032
+ }
1033
+ .cwd-recent-option:hover, .cwd-recent-option[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 14%, transparent); }
1034
+ @media (pointer: coarse) { .cwd-recent-option { min-height: 46px; } }
@@ -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) }