@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 +6 -0
- package/package.json +1 -1
- package/src/manager/folder-browser.mjs +85 -0
- package/src/manager/server.mjs +16 -13
- package/web/app.js +274 -36
- package/web/folders.js +17 -0
- package/web/index.html +50 -2
- package/web/styles.css +63 -4
- package/web/terminal-controls.js +130 -0
- package/src/manager/folder-picker.mjs +0 -116
- package/src/manager/folder-picker.ps1 +0 -84
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.
|
|
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
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 === '/
|
|
282
|
-
|
|
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:
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
741
|
-
offline: 'Connect to the session manager to choose folders'
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
5392
|
-
|
|
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
|
-
<
|
|
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
|
|
572
|
+
.terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
|
|
549
573
|
}
|
|
550
574
|
|
|
551
575
|
.models {
|
|
@@ -558,7 +582,8 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
|
|
|
558
582
|
.models-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
559
583
|
.models-heading { flex: 1; min-width: 0; }
|
|
560
584
|
.models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
|
|
561
|
-
|
|
585
|
+
/* Content supplies the basis for auto-height dialogs; a zero basis collapses in Safari. */
|
|
586
|
+
.models-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 14px 14px; }
|
|
562
587
|
.remote-access { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
|
|
563
588
|
.remote-body { padding: 4px 20px 20px; font-size: 0.9rem; }
|
|
564
589
|
.remote-body > p { color: var(--muted); line-height: 1.6; }
|
|
@@ -968,7 +993,7 @@ a.btn.github-out::after {
|
|
|
968
993
|
@media (max-width: 640px) {
|
|
969
994
|
.topbar { padding: 10px 16px; }
|
|
970
995
|
.connection .label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
971
|
-
.terminal-panel { inset: var(--topbar-h, 56px) 0
|
|
996
|
+
.terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
|
|
972
997
|
.session-grid { grid-template-columns: 1fr; }
|
|
973
998
|
.cwd { flex-basis: 100%; }
|
|
974
999
|
.launch-tools { flex-basis: 100%; justify-content: stretch; }
|
|
@@ -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) }
|