@oddessentials/agent-guild 0.28.0 → 0.29.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/README.md +6 -0
- package/package.json +1 -1
- package/src/manager/folder-browser.mjs +39 -1
- package/src/manager/server.mjs +4 -0
- package/web/app.js +175 -53
- package/web/index.html +16 -1
- package/web/skins/gnomeland/skin.css +2 -2
- package/web/skins/goblinville/skin.css +2 -2
- package/web/skins/grove/skin.css +2 -2
- package/web/skins/guild/skin.css +1 -1
- package/web/skins/orbital/skin.css +2 -2
- package/web/styles.css +38 -6
- package/web/terminal-controls.js +130 -0
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.29.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": {
|
|
@@ -16,6 +16,20 @@ export function folderSegments(dir, paths = path) {
|
|
|
16
16
|
return segments;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(\..*)?$/i;
|
|
20
|
+
|
|
21
|
+
export function folderNameProblem(name, platform = process.platform) {
|
|
22
|
+
if (!name) return 'Enter a folder name.';
|
|
23
|
+
if (name === '.' || name === '..') return 'Choose a different folder name.';
|
|
24
|
+
if (/[/\\]/.test(name)) return 'A folder name cannot contain / or \\.';
|
|
25
|
+
if (/[\u0000-\u001f]/.test(name)) return 'A folder name cannot contain control characters.';
|
|
26
|
+
if (platform !== 'win32') return null;
|
|
27
|
+
if (/[<>:"|?*]/.test(name)) return 'A folder name cannot contain < > : " | ? or *.';
|
|
28
|
+
if (/[. ]$/.test(name)) return 'A folder name cannot end with a dot or a space.';
|
|
29
|
+
if (WINDOWS_RESERVED.test(name)) return `Windows reserves the name ${name}.`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises } = {}) {
|
|
20
34
|
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
21
35
|
|
|
@@ -59,7 +73,30 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
|
|
|
59
73
|
return folders.filter(Boolean).sort(byName);
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
|
|
76
|
+
const browser = {
|
|
77
|
+
async create(rawParent, rawName) {
|
|
78
|
+
if (typeof rawParent !== 'string' || !rawParent.trim() || rawParent.includes('\0')) throw failure(400, 'bad_path', 'The folder must be a path.');
|
|
79
|
+
const name = typeof rawName === 'string' ? rawName.trim() : '';
|
|
80
|
+
const problem = folderNameProblem(name, platform);
|
|
81
|
+
if (problem) throw failure(400, 'bad_name', problem);
|
|
82
|
+
const parent = paths.resolve(rawParent.trim());
|
|
83
|
+
const missing = () => failure(409, 'folder_missing', `${parent} no longer exists.`);
|
|
84
|
+
try {
|
|
85
|
+
if (!(await fsp.stat(parent)).isDirectory()) throw missing();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.status) throw error;
|
|
88
|
+
throw error.code === 'ENOENT' || error.code === 'ENOTDIR' ? missing() : failure(409, 'folder_unreadable', `Agent Guild cannot read ${parent}.`);
|
|
89
|
+
}
|
|
90
|
+
const dir = paths.join(parent, name);
|
|
91
|
+
try {
|
|
92
|
+
await fsp.mkdir(dir);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error.code === 'EEXIST') throw failure(409, 'folder_exists', `${name} already exists in ${parent}.`);
|
|
95
|
+
if (error.code === 'ENOENT') throw missing();
|
|
96
|
+
throw failure(409, 'folder_unwritable', `Agent Guild cannot create ${dir}.`);
|
|
97
|
+
}
|
|
98
|
+
return browser.list(dir);
|
|
99
|
+
},
|
|
63
100
|
async list(raw) {
|
|
64
101
|
if (raw !== undefined && raw !== null && (typeof raw !== 'string' || raw.includes('\0'))) {
|
|
65
102
|
throw failure(400, 'bad_path', 'The folder must be a path.');
|
|
@@ -82,4 +119,5 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
|
|
|
82
119
|
};
|
|
83
120
|
},
|
|
84
121
|
};
|
|
122
|
+
return browser;
|
|
85
123
|
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -287,6 +287,10 @@ export function createManagerServer({
|
|
|
287
287
|
if (route === '/folders' && method === 'GET') {
|
|
288
288
|
return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined));
|
|
289
289
|
}
|
|
290
|
+
if (route === '/folders' && method === 'POST') {
|
|
291
|
+
const { path: parent, name } = await readJsonBody(req);
|
|
292
|
+
return sendJson(res, 201, await folderBrowser.create(parent, name));
|
|
293
|
+
}
|
|
290
294
|
if (route === '/upgrade' && method === 'POST') {
|
|
291
295
|
const session = await manager.upgrade();
|
|
292
296
|
return sendJson(res, 201, { session: session.toJSON() });
|
package/web/app.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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';
|
|
@@ -22,6 +23,7 @@ const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
|
|
|
22
23
|
const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
|
|
23
24
|
const HIDDEN_FOLDERS_KEY = 'agentGuild.showHiddenFolders';
|
|
24
25
|
const RECENT_CWDS_KEY = 'agentGuild.recentCwds';
|
|
26
|
+
const RECENT_CLONE_PARENTS_KEY = 'agentGuild.recentCloneParents';
|
|
25
27
|
const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
|
|
26
28
|
const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
|
|
27
29
|
const GITHUB_VIEW_KEY = 'agentGuild.githubView';
|
|
@@ -124,6 +126,7 @@ function setConnection(kind, label) {
|
|
|
124
126
|
el.title = label;
|
|
125
127
|
// The manager can only be stopped, restarted or upgraded while the page can reach it.
|
|
126
128
|
state.connected = kind === 'ok';
|
|
129
|
+
terminalControls.refresh();
|
|
127
130
|
state.remoteAccessUI?.connectionChanged();
|
|
128
131
|
$('manager').hidden = !state.connected;
|
|
129
132
|
if (!state.connected) closeMenu($('manager-menu'));
|
|
@@ -765,68 +768,78 @@ async function openWorkingFolder() {
|
|
|
765
768
|
}
|
|
766
769
|
}
|
|
767
770
|
|
|
768
|
-
// ---- recent
|
|
771
|
+
// ---- recent folders ---------------------------------------------------------
|
|
769
772
|
|
|
770
|
-
const
|
|
773
|
+
const RECENT_FIELDS = {
|
|
774
|
+
cwd: { input: 'cwd', list: 'cwd-recent', key: RECENT_CWDS_KEY, pick: (dir) => useFolder(dir) },
|
|
775
|
+
clone: { input: 'github-parent', list: 'github-parent-recent', key: RECENT_CLONE_PARENTS_KEY, pick: (dir) => setCloneParent(dir) },
|
|
776
|
+
};
|
|
777
|
+
const recentView = { field: null, typed: false, active: -1 };
|
|
771
778
|
|
|
772
|
-
function
|
|
773
|
-
return readRecentFolders(load(
|
|
779
|
+
function recentFolders(field) {
|
|
780
|
+
return readRecentFolders(load(RECENT_FIELDS[field].key));
|
|
774
781
|
}
|
|
775
782
|
|
|
776
|
-
function
|
|
777
|
-
if (dir) save(
|
|
783
|
+
function rememberRecent(field, dir) {
|
|
784
|
+
if (dir) save(RECENT_FIELDS[field].key, JSON.stringify(rememberFolder(recentFolders(field), dir, { caseless: state.platform === 'win32' })));
|
|
778
785
|
}
|
|
779
786
|
|
|
780
|
-
function
|
|
781
|
-
const input
|
|
782
|
-
const
|
|
783
|
-
|
|
787
|
+
function renderRecent(field) {
|
|
788
|
+
const { input: inputId, list: listId } = RECENT_FIELDS[field];
|
|
789
|
+
const input = $(inputId), list = $(listId);
|
|
790
|
+
const open = recentView.field === field;
|
|
791
|
+
const shown = open ? matchFolders(recentFolders(field), recentView.typed ? input.value : '') : [];
|
|
792
|
+
if (open) recentView.active = Math.min(recentView.active, shown.length - 1);
|
|
784
793
|
list.replaceChildren(...shown.map((dir, i) => {
|
|
785
794
|
const option = el('li', 'cwd-recent-option', dir);
|
|
786
|
-
option.id =
|
|
795
|
+
option.id = `${listId}-${i}`;
|
|
787
796
|
option.setAttribute('role', 'option');
|
|
788
|
-
option.setAttribute('aria-selected', String(i === recentView.active));
|
|
797
|
+
option.setAttribute('aria-selected', String(open && i === recentView.active));
|
|
789
798
|
option.addEventListener('pointerdown', (e) => e.preventDefault());
|
|
790
|
-
option.addEventListener('click', () =>
|
|
799
|
+
option.addEventListener('click', () => pickRecent(field, dir));
|
|
791
800
|
return option;
|
|
792
801
|
}));
|
|
793
802
|
list.hidden = !shown.length;
|
|
794
803
|
input.setAttribute('aria-expanded', String(!list.hidden));
|
|
795
|
-
if (recentView.active >= 0) input.setAttribute('aria-activedescendant',
|
|
804
|
+
if (open && recentView.active >= 0) input.setAttribute('aria-activedescendant', `${listId}-${recentView.active}`);
|
|
796
805
|
else input.removeAttribute('aria-activedescendant');
|
|
797
806
|
}
|
|
798
807
|
|
|
799
|
-
function
|
|
800
|
-
|
|
801
|
-
|
|
808
|
+
function showRecent(field, typed = false) {
|
|
809
|
+
const previous = recentView.field;
|
|
810
|
+
Object.assign(recentView, { field, typed, active: -1 });
|
|
811
|
+
if (previous && previous !== field) renderRecent(previous);
|
|
812
|
+
renderRecent(field);
|
|
802
813
|
}
|
|
803
814
|
|
|
804
|
-
function
|
|
805
|
-
|
|
806
|
-
|
|
815
|
+
function hideRecent(field) {
|
|
816
|
+
if (recentView.field !== field) return;
|
|
817
|
+
Object.assign(recentView, { field: null, active: -1 });
|
|
818
|
+
renderRecent(field);
|
|
807
819
|
}
|
|
808
820
|
|
|
809
|
-
function
|
|
810
|
-
|
|
811
|
-
|
|
821
|
+
function pickRecent(field, dir) {
|
|
822
|
+
hideRecent(field);
|
|
823
|
+
RECENT_FIELDS[field].pick(dir);
|
|
812
824
|
}
|
|
813
825
|
|
|
814
|
-
function
|
|
815
|
-
const
|
|
826
|
+
function recentKeys(field, e) {
|
|
827
|
+
const list = $(RECENT_FIELDS[field].list);
|
|
828
|
+
const options = list.hidden ? [] : [...list.children];
|
|
816
829
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
817
830
|
e.preventDefault();
|
|
818
|
-
if (
|
|
819
|
-
const count =
|
|
831
|
+
if (recentView.field !== field) showRecent(field);
|
|
832
|
+
const count = list.children.length;
|
|
820
833
|
if (!count) return;
|
|
821
834
|
recentView.active = e.key === 'ArrowDown' ? (recentView.active + 1) % count : recentView.active <= 0 ? count - 1 : recentView.active - 1;
|
|
822
|
-
|
|
823
|
-
$(
|
|
835
|
+
renderRecent(field);
|
|
836
|
+
$(`${RECENT_FIELDS[field].list}-${recentView.active}`).scrollIntoView?.({ block: 'nearest' });
|
|
824
837
|
} else if (e.key === 'Enter' && recentView.active >= 0 && options[recentView.active]) {
|
|
825
838
|
e.preventDefault();
|
|
826
|
-
|
|
839
|
+
pickRecent(field, options[recentView.active].textContent);
|
|
827
840
|
} else if (e.key === 'Escape' && options.length) {
|
|
828
841
|
e.preventDefault();
|
|
829
|
-
|
|
842
|
+
hideRecent(field);
|
|
830
843
|
}
|
|
831
844
|
}
|
|
832
845
|
|
|
@@ -852,7 +865,52 @@ function chooseFolder(field) {
|
|
|
852
865
|
browseTo($(FOLDER_FIELDS[field].input).value.trim());
|
|
853
866
|
}
|
|
854
867
|
|
|
868
|
+
function showNewFolder(open) {
|
|
869
|
+
$('folder-new').hidden = !open;
|
|
870
|
+
$('folder-new-open').setAttribute('aria-expanded', String(open));
|
|
871
|
+
if (open) $('folder-new-name').value = '';
|
|
872
|
+
renderFolderBrowser();
|
|
873
|
+
(open ? $('folder-new-name') : $('folder-new-open')).focus();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function createFolder(e) {
|
|
877
|
+
e?.preventDefault();
|
|
878
|
+
const parent = folderView.listing?.path;
|
|
879
|
+
const name = $('folder-new-name').value.trim();
|
|
880
|
+
if (!parent || !name || folderView.loading) return;
|
|
881
|
+
const request = ++folderView.request;
|
|
882
|
+
folderView.loading = true;
|
|
883
|
+
folderView.error = null;
|
|
884
|
+
renderFolderBrowser();
|
|
885
|
+
try {
|
|
886
|
+
const listing = await api('POST', '/folders', { path: parent, name });
|
|
887
|
+
if (request !== folderView.request) return;
|
|
888
|
+
folderView.listing = listing;
|
|
889
|
+
$('folder-filter').value = '';
|
|
890
|
+
$('folder-new').hidden = true;
|
|
891
|
+
$('folder-new-open').setAttribute('aria-expanded', 'false');
|
|
892
|
+
} catch (err) {
|
|
893
|
+
if (request !== folderView.request) return;
|
|
894
|
+
if (err instanceof AuthError) {
|
|
895
|
+
$('folder-browser').close();
|
|
896
|
+
return showAuth(err.message);
|
|
897
|
+
}
|
|
898
|
+
folderView.error = err.message || 'Could not create the folder.';
|
|
899
|
+
}
|
|
900
|
+
folderView.loading = false;
|
|
901
|
+
renderFolderBrowser();
|
|
902
|
+
if ($('folder-browser').open) ($('folder-new').hidden ? $('folder-use') : $('folder-new-name')).focus();
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function newFolderKeys(e) {
|
|
906
|
+
if (e.key !== 'Escape') return;
|
|
907
|
+
e.preventDefault();
|
|
908
|
+
showNewFolder(false);
|
|
909
|
+
}
|
|
910
|
+
|
|
855
911
|
async function browseTo(dir) {
|
|
912
|
+
$('folder-new').hidden = true;
|
|
913
|
+
$('folder-new-open').setAttribute('aria-expanded', 'false');
|
|
856
914
|
const request = ++folderView.request;
|
|
857
915
|
folderView.loading = true;
|
|
858
916
|
folderView.error = null;
|
|
@@ -906,6 +964,8 @@ function renderFolderBrowser() {
|
|
|
906
964
|
status.classList.toggle('error', Boolean(error));
|
|
907
965
|
$('folder-current').textContent = listing?.path ?? '';
|
|
908
966
|
$('folder-use').disabled = loading || !listing;
|
|
967
|
+
$('folder-new-open').disabled = loading || !listing;
|
|
968
|
+
$('folder-new-create').disabled = loading || !listing || !$('folder-new-name').value.trim();
|
|
909
969
|
}
|
|
910
970
|
|
|
911
971
|
function folderBrowserClosed() {
|
|
@@ -923,11 +983,9 @@ function useBrowsedFolder() {
|
|
|
923
983
|
if (!dir || folderView.loading) return;
|
|
924
984
|
const field = folderView.field;
|
|
925
985
|
$('folder-browser').close();
|
|
986
|
+
rememberRecent(field, dir);
|
|
926
987
|
if (field === 'clone') setCloneParent(dir);
|
|
927
|
-
else
|
|
928
|
-
rememberCwd(dir);
|
|
929
|
-
useFolder(dir);
|
|
930
|
-
}
|
|
988
|
+
else useFolder(dir);
|
|
931
989
|
}
|
|
932
990
|
|
|
933
991
|
function moveInFolders(e) {
|
|
@@ -2416,7 +2474,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
|
|
|
2416
2474
|
({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
|
|
2417
2475
|
}
|
|
2418
2476
|
upsertSession(session);
|
|
2419
|
-
|
|
2477
|
+
rememberRecent('cwd', session.cwd);
|
|
2420
2478
|
closeHistory({ focusOpener: false });
|
|
2421
2479
|
openPanel(session.id);
|
|
2422
2480
|
} catch (err) {
|
|
@@ -2626,7 +2684,7 @@ const GITHUB_SCOPES = {
|
|
|
2626
2684
|
repo: 'Read and write access to your repositories, private ones included, so you can browse repositories and create or edit issues.',
|
|
2627
2685
|
'write:public_key': 'Add the SSH key Agent Guild creates for this account.',
|
|
2628
2686
|
};
|
|
2629
|
-
const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, card: null, started: new
|
|
2687
|
+
const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, card: null, started: new Map(), announced: new Set() };
|
|
2630
2688
|
let githubLoading = null;
|
|
2631
2689
|
|
|
2632
2690
|
function el(tag, className, ...children) {
|
|
@@ -3091,8 +3149,9 @@ function renderGitHubRepos(github, account, repos) {
|
|
|
3091
3149
|
}
|
|
3092
3150
|
|
|
3093
3151
|
async function startClone(account, fullName) {
|
|
3094
|
-
const
|
|
3095
|
-
|
|
3152
|
+
const parent = cloneParent();
|
|
3153
|
+
const { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent });
|
|
3154
|
+
githubView.started.set(session.id, parent);
|
|
3096
3155
|
upsertSession(session);
|
|
3097
3156
|
openPanel(session.id);
|
|
3098
3157
|
}
|
|
@@ -3202,8 +3261,10 @@ function noticeClone(s) {
|
|
|
3202
3261
|
githubPick.origins.clear();
|
|
3203
3262
|
if (dockShows('github')) loadRepos();
|
|
3204
3263
|
if (!githubView.started.has(s.id)) return;
|
|
3205
|
-
if (clonedPath(s))
|
|
3206
|
-
|
|
3264
|
+
if (clonedPath(s)) {
|
|
3265
|
+
rememberRecent('clone', githubView.started.get(s.id));
|
|
3266
|
+
toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
|
|
3267
|
+
} else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
|
|
3207
3268
|
}
|
|
3208
3269
|
|
|
3209
3270
|
function useFolder(dir) {
|
|
@@ -4426,11 +4487,18 @@ class TerminalView {
|
|
|
4426
4487
|
|
|
4427
4488
|
connect() {
|
|
4428
4489
|
if (this.disposed || state.remoteRevoked) return;
|
|
4490
|
+
this.inputReady = false;
|
|
4491
|
+
this.inputSnapshot = null;
|
|
4492
|
+
terminalControls.refresh();
|
|
4429
4493
|
const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
|
|
4430
4494
|
this.ws = ws;
|
|
4431
4495
|
ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
|
|
4432
|
-
ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
|
|
4496
|
+
ws.onmessage = (event) => { if (!this.disposed && this.ws === ws) this.onMessage(JSON.parse(event.data)); };
|
|
4433
4497
|
ws.onclose = (event) => {
|
|
4498
|
+
if (this.ws !== ws) return;
|
|
4499
|
+
this.inputReady = false;
|
|
4500
|
+
this.inputSnapshot = null;
|
|
4501
|
+
terminalControls.refresh();
|
|
4434
4502
|
if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
|
|
4435
4503
|
const delay = Math.min(5000, 300 * 2 ** this.retry++);
|
|
4436
4504
|
setTimeout(() => this.connect(), delay);
|
|
@@ -4440,14 +4508,31 @@ class TerminalView {
|
|
|
4440
4508
|
onMessage(msg) {
|
|
4441
4509
|
switch (msg.type) {
|
|
4442
4510
|
case 'snapshot':
|
|
4511
|
+
this.inputReady = false;
|
|
4512
|
+
this.inputSnapshot = msg;
|
|
4513
|
+
this.inputRun = msg.session.startedAt;
|
|
4514
|
+
terminalControls.refresh();
|
|
4443
4515
|
this.term.reset();
|
|
4444
4516
|
this.term.resize(msg.cols, msg.rows);
|
|
4445
|
-
|
|
4517
|
+
{
|
|
4518
|
+
const ws = this.ws;
|
|
4519
|
+
this.term.write(msg.data, () => {
|
|
4520
|
+
if (this.disposed || this.ws !== ws || ws.readyState !== WebSocket.OPEN || this.inputSnapshot !== msg) return;
|
|
4521
|
+
this.inputReady = msg.session.status === 'running';
|
|
4522
|
+
this.inputSnapshot = null;
|
|
4523
|
+
this.sent = { cols: 0, rows: 0 };
|
|
4524
|
+
this.refit();
|
|
4525
|
+
terminalControls.refresh();
|
|
4526
|
+
});
|
|
4527
|
+
}
|
|
4446
4528
|
break;
|
|
4447
4529
|
case 'data':
|
|
4448
4530
|
this.term.write(msg.data);
|
|
4449
4531
|
break;
|
|
4450
4532
|
case 'exit':
|
|
4533
|
+
this.inputReady = false;
|
|
4534
|
+
this.inputSnapshot = null;
|
|
4535
|
+
terminalControls.refresh();
|
|
4451
4536
|
if (dictation?.id === this.id) stopDictation();
|
|
4452
4537
|
this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
|
|
4453
4538
|
break;
|
|
@@ -4532,6 +4617,9 @@ class TerminalView {
|
|
|
4532
4617
|
|
|
4533
4618
|
dispose() {
|
|
4534
4619
|
this.disposed = true;
|
|
4620
|
+
this.inputReady = false;
|
|
4621
|
+
this.inputSnapshot = null;
|
|
4622
|
+
terminalControls.refresh();
|
|
4535
4623
|
this.resizeObserver.disconnect();
|
|
4536
4624
|
this.ws?.close();
|
|
4537
4625
|
this.term.dispose();
|
|
@@ -4541,6 +4629,20 @@ class TerminalView {
|
|
|
4541
4629
|
|
|
4542
4630
|
// ---- terminal panel -------------------------------------------------------
|
|
4543
4631
|
|
|
4632
|
+
const terminalControls = new TerminalControls({
|
|
4633
|
+
element: $('terminal-controls'), panel: $('terminal-panel'),
|
|
4634
|
+
getCurrent: () => {
|
|
4635
|
+
const session = state.sessions.get(state.activeId);
|
|
4636
|
+
const view = state.views.get(state.activeId);
|
|
4637
|
+
if (!session || session.status !== 'running' || !view?.inputReady || view.disposed
|
|
4638
|
+
|| session.startedAt !== view.inputRun || view.ws?.readyState !== WebSocket.OPEN
|
|
4639
|
+
|| !state.connected || state.pageAway || state.remoteRevoked || state.stopping
|
|
4640
|
+
|| $('app').hidden || $('terminal-panel').hidden || !view.el.isConnected) return null;
|
|
4641
|
+
return { term: view.term, socket: view.ws, run: session.startedAt, name: session.name };
|
|
4642
|
+
},
|
|
4643
|
+
});
|
|
4644
|
+
bindTerminalViewport($('terminal-panel'), $('terminal-controls'));
|
|
4645
|
+
|
|
4544
4646
|
const terminalCopy = new TerminalCopy({
|
|
4545
4647
|
opener: $('panel-copy'), dialog: $('terminal-copy'),
|
|
4546
4648
|
getCurrent: () => {
|
|
@@ -4578,7 +4680,7 @@ function openPanel(id, { beside = false, restoring = false } = {}) {
|
|
|
4578
4680
|
function focusPane(index, { focusTerminal = true } = {}) {
|
|
4579
4681
|
const id = state.panes[index];
|
|
4580
4682
|
if (!id) return;
|
|
4581
|
-
if (state.activeId !== id) terminalCopy.close();
|
|
4683
|
+
if (state.activeId !== id) { terminalCopy.close(); terminalControls.cancel(); }
|
|
4582
4684
|
if (dictation && dictation.id !== id) stopDictation();
|
|
4583
4685
|
state.focusedPane = index;
|
|
4584
4686
|
state.activeId = id;
|
|
@@ -4599,6 +4701,7 @@ function closePane(index, { focusTerminal = true } = {}) {
|
|
|
4599
4701
|
}
|
|
4600
4702
|
|
|
4601
4703
|
function closePanel() {
|
|
4704
|
+
terminalControls.cancel();
|
|
4602
4705
|
terminalCopy.close();
|
|
4603
4706
|
stopDictation();
|
|
4604
4707
|
for (const id of state.panes) state.views.get(id)?.unmount();
|
|
@@ -4613,7 +4716,8 @@ function closePanel() {
|
|
|
4613
4716
|
|
|
4614
4717
|
function stageSplit() {
|
|
4615
4718
|
const box = $('terminal-panel').getBoundingClientRect();
|
|
4616
|
-
|
|
4719
|
+
const controls = $('terminal-controls');
|
|
4720
|
+
return splitMode(box.width, box.height - (controls.hidden ? 0 : controls.offsetHeight));
|
|
4617
4721
|
}
|
|
4618
4722
|
|
|
4619
4723
|
function layoutPanes() {
|
|
@@ -4709,6 +4813,7 @@ function restorePanes() {
|
|
|
4709
4813
|
}
|
|
4710
4814
|
|
|
4711
4815
|
function updatePanel() {
|
|
4816
|
+
terminalControls.refresh();
|
|
4712
4817
|
renderVoice();
|
|
4713
4818
|
const s = state.sessions.get(state.activeId);
|
|
4714
4819
|
if (!s) return;
|
|
@@ -5222,7 +5327,12 @@ bindSplitter($('pane-splitter'), {
|
|
|
5222
5327
|
home: () => setSplitRatio(SPLIT_RATIO_MIN),
|
|
5223
5328
|
endKey: () => setSplitRatio(1 - SPLIT_RATIO_MIN),
|
|
5224
5329
|
});
|
|
5225
|
-
|
|
5330
|
+
let terminalLayoutFrame;
|
|
5331
|
+
const terminalLayoutObserver = new ResizeObserver(() => {
|
|
5332
|
+
cancelAnimationFrame(terminalLayoutFrame);
|
|
5333
|
+
terminalLayoutFrame = requestAnimationFrame(layoutPanes);
|
|
5334
|
+
});
|
|
5335
|
+
for (const node of [$('terminal-panel'), $('terminal-controls')]) terminalLayoutObserver.observe(node);
|
|
5226
5336
|
$('panel-voice').addEventListener('click', () => {
|
|
5227
5337
|
if (dictation) {
|
|
5228
5338
|
const { id } = dictation;
|
|
@@ -5536,11 +5646,13 @@ $('panel-stop').addEventListener('click', () => {
|
|
|
5536
5646
|
});
|
|
5537
5647
|
$('cwd').value = load(CWD_KEY) || '';
|
|
5538
5648
|
$('cwd-pick').addEventListener('click', firstClick(() => chooseFolder('cwd')));
|
|
5539
|
-
|
|
5540
|
-
$(
|
|
5541
|
-
$(
|
|
5542
|
-
$(
|
|
5543
|
-
$(
|
|
5649
|
+
for (const [field, { input }] of Object.entries(RECENT_FIELDS)) {
|
|
5650
|
+
$(input).addEventListener('focus', () => showRecent(field));
|
|
5651
|
+
$(input).addEventListener('click', () => { if (recentView.field !== field) showRecent(field); });
|
|
5652
|
+
$(input).addEventListener('input', () => showRecent(field, true));
|
|
5653
|
+
$(input).addEventListener('blur', () => hideRecent(field));
|
|
5654
|
+
$(input).addEventListener('keydown', (e) => recentKeys(field, e));
|
|
5655
|
+
}
|
|
5544
5656
|
$('folder-cancel').addEventListener('click', () => $('folder-browser').close());
|
|
5545
5657
|
$('folder-browser').addEventListener('click', (e) => { if (e.target === $('folder-browser')) $('folder-browser').close(); });
|
|
5546
5658
|
$('folder-browser').addEventListener('close', folderBrowserClosed);
|
|
@@ -5554,6 +5666,11 @@ $('folder-hidden').addEventListener('change', (e) => {
|
|
|
5554
5666
|
renderFolderBrowser();
|
|
5555
5667
|
});
|
|
5556
5668
|
$('folder-use').addEventListener('click', useBrowsedFolder);
|
|
5669
|
+
$('folder-new-open').addEventListener('click', () => showNewFolder($('folder-new').hidden));
|
|
5670
|
+
$('folder-new-cancel').addEventListener('click', () => showNewFolder(false));
|
|
5671
|
+
$('folder-new').addEventListener('submit', createFolder);
|
|
5672
|
+
$('folder-new-name').addEventListener('input', renderFolderBrowser);
|
|
5673
|
+
$('folder-new-name').addEventListener('keydown', newFolderKeys);
|
|
5557
5674
|
$('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
|
|
5558
5675
|
try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
|
|
5559
5676
|
try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
|
|
@@ -5570,8 +5687,13 @@ setInterval(() => { if (dockShows('github') && state.github) renderGitHub(); },
|
|
|
5570
5687
|
// The terminal panel and the dock sit below the top bar; publish where it ends so they never cover its controls.
|
|
5571
5688
|
const topbar = document.querySelector('.topbar');
|
|
5572
5689
|
const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${Math.max(0, Math.round(topbar.getBoundingClientRect().bottom))}px`);
|
|
5573
|
-
|
|
5574
|
-
|
|
5690
|
+
let topbarFrame;
|
|
5691
|
+
const scheduleTopbarHeight = () => {
|
|
5692
|
+
cancelAnimationFrame(topbarFrame);
|
|
5693
|
+
topbarFrame = requestAnimationFrame(publishTopbarHeight);
|
|
5694
|
+
};
|
|
5695
|
+
new ResizeObserver(scheduleTopbarHeight).observe(topbar);
|
|
5696
|
+
addEventListener('scroll', scheduleTopbarHeight, { passive: true });
|
|
5575
5697
|
publishTopbarHeight();
|
|
5576
5698
|
applyDockLayout();
|
|
5577
5699
|
|
package/web/index.html
CHANGED
|
@@ -169,6 +169,14 @@
|
|
|
169
169
|
<div class="terminal-host"></div>
|
|
170
170
|
</section>
|
|
171
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>
|
|
172
180
|
<div id="voice-preview" class="voice-preview" hidden aria-live="polite"></div>
|
|
173
181
|
</div>
|
|
174
182
|
|
|
@@ -236,7 +244,8 @@
|
|
|
236
244
|
<div id="github-clone-into" class="history-form github-form">
|
|
237
245
|
<label for="github-parent">Clone into</label>
|
|
238
246
|
<div class="cwd-field single">
|
|
239
|
-
<input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off">
|
|
247
|
+
<input id="github-parent" type="text" placeholder="Folder that holds your clones" spellcheck="false" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="github-parent-recent">
|
|
248
|
+
<ul id="github-parent-recent" class="cwd-recent above" role="listbox" aria-label="Recent clone folders" hidden></ul>
|
|
240
249
|
<div class="cwd-tools">
|
|
241
250
|
<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
251
|
<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">
|
|
@@ -426,7 +435,13 @@
|
|
|
426
435
|
<button id="folder-up" class="btn" type="button">Up</button>
|
|
427
436
|
<button id="folder-home" class="btn folder-chip" type="button">Home</button>
|
|
428
437
|
<div id="folder-roots" class="folder-roots"></div>
|
|
438
|
+
<button id="folder-new-open" class="btn folder-new-open" type="button" aria-expanded="false" aria-controls="folder-new">New folder</button>
|
|
429
439
|
</div>
|
|
440
|
+
<form id="folder-new" class="history-tools folder-new" hidden>
|
|
441
|
+
<input id="folder-new-name" type="text" placeholder="New folder name" aria-label="New folder name" spellcheck="false" autocomplete="off" autocapitalize="off" enterkeyhint="done">
|
|
442
|
+
<button id="folder-new-create" class="btn primary" type="submit">Create</button>
|
|
443
|
+
<button id="folder-new-cancel" class="btn" type="button">Cancel</button>
|
|
444
|
+
</form>
|
|
430
445
|
<div class="history-tools">
|
|
431
446
|
<input id="folder-filter" type="search" placeholder="Filter folders" aria-label="Filter folders" spellcheck="false" autocomplete="off">
|
|
432
447
|
<label class="history-here"><input id="folder-hidden" type="checkbox"><span>Show hidden</span></label>
|
|
@@ -243,7 +243,7 @@
|
|
|
243
243
|
}
|
|
244
244
|
.btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 0 0 1px rgba(233, 196, 122, 0.6), 0 5px 16px rgba(40, 70, 30, 0.35); }
|
|
245
245
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
246
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
246
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
247
247
|
|
|
248
248
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--panel-strong); backdrop-filter: blur(16px); }
|
|
249
249
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -362,7 +362,7 @@
|
|
|
362
362
|
.btn.primary { border-color: #6a8f3a; background: linear-gradient(#55924a, #35662f); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 0 0 1px rgba(233, 196, 122, 0.3), 0 0 16px rgba(110, 170, 80, 0.3); }
|
|
363
363
|
.btn.danger { color: #f59a8c; }
|
|
364
364
|
.btn.danger:hover { border-color: #b8402f; box-shadow: 0 0 12px rgba(240, 123, 106, 0.3); }
|
|
365
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #4a3824; background: rgba(10, 7, 4, 0.7); }
|
|
365
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #4a3824; background: rgba(10, 7, 4, 0.7); }
|
|
366
366
|
|
|
367
367
|
.models::backdrop, .news-panel::backdrop { background: rgba(6, 4, 2, 0.66); }
|
|
368
368
|
.terminal-panel { border-color: #5a4226; box-shadow: 0 0 0 1px rgba(233, 196, 122, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
|
@@ -249,7 +249,7 @@
|
|
|
249
249
|
}
|
|
250
250
|
.btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 0 0 1px rgba(227, 166, 119, 0.6), 0 5px 16px rgba(20, 70, 56, 0.35); }
|
|
251
251
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
252
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
252
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
|
|
253
253
|
|
|
254
254
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--panel-strong); backdrop-filter: blur(16px); }
|
|
255
255
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -369,7 +369,7 @@
|
|
|
369
369
|
.btn.primary { border-color: #3f9a80; background: linear-gradient(#2f8a73, #1b5a4b); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 0 0 1px rgba(227, 166, 119, 0.3), 0 0 16px rgba(60, 170, 140, 0.3); }
|
|
370
370
|
.btn.danger { color: #f59a8c; }
|
|
371
371
|
.btn.danger:hover { border-color: #b8402f; box-shadow: 0 0 12px rgba(240, 123, 106, 0.3); }
|
|
372
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #3a4a3f; background: rgba(5, 8, 6, 0.7); }
|
|
372
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #3a4a3f; background: rgba(5, 8, 6, 0.7); }
|
|
373
373
|
|
|
374
374
|
.models::backdrop, .news-panel::backdrop { background: rgba(2, 5, 3, 0.66); }
|
|
375
375
|
.terminal-panel { border-color: #5a3a22; box-shadow: 0 0 0 1px rgba(227, 166, 119, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
package/web/skins/grove/skin.css
CHANGED
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
}
|
|
245
245
|
.btn.primary:hover { filter: brightness(1.07); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 6px 20px rgba(45, 107, 69, 0.36); }
|
|
246
246
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
247
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--paper-strong); }
|
|
247
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--paper-strong); }
|
|
248
248
|
|
|
249
249
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--paper-strong); backdrop-filter: blur(16px); }
|
|
250
250
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -371,7 +371,7 @@
|
|
|
371
371
|
.btn.primary { background: linear-gradient(135deg, #4a8c5e, #2a6340); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 18px rgba(80, 160, 105, 0.35); }
|
|
372
372
|
.btn.danger { color: #ff9a8e; }
|
|
373
373
|
.btn.danger:hover { border-color: #b8443a; box-shadow: 0 0 12px rgba(240, 122, 108, 0.3); }
|
|
374
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #2e4a3d; background: rgba(4, 10, 8, 0.7); }
|
|
374
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #2e4a3d; background: rgba(4, 10, 8, 0.7); }
|
|
375
375
|
|
|
376
376
|
.models::backdrop, .news-panel::backdrop { background: rgba(2, 6, 5, 0.66); }
|
|
377
377
|
.terminal-panel { border-color: #2e4a3d; box-shadow: 0 0 0 1px rgba(140, 210, 160, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
package/web/skins/guild/skin.css
CHANGED
|
@@ -480,7 +480,7 @@
|
|
|
480
480
|
}
|
|
481
481
|
.btn.primary:hover { border-color: #b8892f; filter: brightness(1.08); }
|
|
482
482
|
.btn.danger:hover { border-color: #c4625d; }
|
|
483
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .notes-text {
|
|
483
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .notes-text {
|
|
484
484
|
border-color: #dccba5; background: #fffdf8; box-shadow: inset 0 1px 2px rgba(70, 48, 12, 0.06);
|
|
485
485
|
}
|
|
486
486
|
.auth { border-color: #e4d6b6; box-shadow: var(--card-shadow); }
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
}
|
|
247
247
|
.btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 20px rgba(122, 79, 232, 0.4); }
|
|
248
248
|
.btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
|
|
249
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--glass-strong); }
|
|
249
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--glass-strong); }
|
|
250
250
|
|
|
251
251
|
.models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--glass-strong); backdrop-filter: blur(16px); }
|
|
252
252
|
.models, .news-panel { box-shadow: var(--dialog-shadow); }
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
.btn.primary { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 0 18px rgba(106, 92, 240, 0.45); }
|
|
368
368
|
.btn.danger { color: #ff8c9c; }
|
|
369
369
|
.btn.danger:hover { border-color: #c23a52; box-shadow: 0 0 12px rgba(255, 107, 129, 0.3); }
|
|
370
|
-
.cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #26355c; background: rgba(4, 7, 16, 0.7); }
|
|
370
|
+
.cwd input, .auth input, .history-tools input[type="search"], .folder-new input, .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #26355c; background: rgba(4, 7, 16, 0.7); }
|
|
371
371
|
|
|
372
372
|
.models::backdrop, .news-panel::backdrop { background: rgba(2, 4, 10, 0.66); }
|
|
373
373
|
.terminal-panel { border-color: #2a3a66; box-shadow: 0 0 0 1px rgba(120, 170, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
|
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; }
|
|
@@ -724,7 +749,7 @@ a.news-link:visited { color: var(--muted); }
|
|
|
724
749
|
}
|
|
725
750
|
|
|
726
751
|
.history-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 14px 0; }
|
|
727
|
-
.history-tools input[type="search"] {
|
|
752
|
+
.history-tools input[type="search"], .folder-new input {
|
|
728
753
|
flex: 1 1 220px; min-width: 0; padding: 7px 10px; border-radius: var(--radius); border: 1px solid var(--border);
|
|
729
754
|
background: var(--surface); color: var(--text); font: inherit; font-size: 0.9rem;
|
|
730
755
|
}
|
|
@@ -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; }
|
|
@@ -1010,6 +1035,9 @@ a.btn.github-out::after {
|
|
|
1010
1035
|
.folder-crumb:focus-visible, .folder-row:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
1011
1036
|
.folder-places, .folder-roots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1012
1037
|
.folder-places .btn { min-height: 36px; }
|
|
1038
|
+
.folder-new-open { margin-left: auto; }
|
|
1039
|
+
.folder-new { flex-wrap: nowrap; }
|
|
1040
|
+
.folder-new input { flex: 1; min-width: 0; }
|
|
1013
1041
|
.folder-list { display: flex; flex-direction: column; }
|
|
1014
1042
|
.folder-row {
|
|
1015
1043
|
display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; border: 0; border-bottom: 1px solid var(--border);
|
|
@@ -1022,6 +1050,9 @@ a.btn.github-out::after {
|
|
|
1022
1050
|
.folder-footer { justify-content: space-between; }
|
|
1023
1051
|
.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
1052
|
.folder-footer .btn { min-height: 42px; }
|
|
1053
|
+
@media (pointer: coarse), (max-width: 640px) {
|
|
1054
|
+
.folder-places .btn, .folder-new .btn, .folder-new input, .folder-footer .btn, #folder-cancel { min-height: 44px; }
|
|
1055
|
+
}
|
|
1025
1056
|
.cwd-recent {
|
|
1026
1057
|
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 5; margin: 0; padding: 4px; list-style: none;
|
|
1027
1058
|
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
|
@@ -1031,4 +1062,5 @@ a.btn.github-out::after {
|
|
|
1031
1062
|
color: var(--text); font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; overflow-wrap: anywhere; cursor: pointer;
|
|
1032
1063
|
}
|
|
1033
1064
|
.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; } }
|
|
1065
|
+
@media (pointer: coarse), (max-width: 640px) { .cwd-recent-option { min-height: 46px; } }
|
|
1066
|
+
.cwd-recent.above { top: auto; bottom: calc(100% + 4px); }
|
|
@@ -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
|
+
}
|