@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 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.28.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
- return {
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
  }
@@ -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 working folders -----------------------------------------------
771
+ // ---- recent folders ---------------------------------------------------------
769
772
 
770
- const recentView = { open: false, typed: false, active: -1 };
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 recentCwds() {
773
- return readRecentFolders(load(RECENT_CWDS_KEY));
779
+ function recentFolders(field) {
780
+ return readRecentFolders(load(RECENT_FIELDS[field].key));
774
781
  }
775
782
 
776
- function rememberCwd(dir) {
777
- if (dir) save(RECENT_CWDS_KEY, JSON.stringify(rememberFolder(recentCwds(), dir, { caseless: state.platform === 'win32' })));
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 renderRecentCwds() {
781
- const input = $('cwd'), list = $('cwd-recent');
782
- const shown = recentView.open ? matchFolders(recentCwds(), recentView.typed ? input.value : '') : [];
783
- recentView.active = Math.min(recentView.active, shown.length - 1);
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 = `cwd-recent-${i}`;
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', () => pickRecentCwd(dir));
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', `cwd-recent-${recentView.active}`);
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 showRecentCwds(typed = false) {
800
- Object.assign(recentView, { open: true, typed, active: -1 });
801
- renderRecentCwds();
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 hideRecentCwds() {
805
- Object.assign(recentView, { open: false, active: -1 });
806
- renderRecentCwds();
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 pickRecentCwd(dir) {
810
- hideRecentCwds();
811
- useFolder(dir);
821
+ function pickRecent(field, dir) {
822
+ hideRecent(field);
823
+ RECENT_FIELDS[field].pick(dir);
812
824
  }
813
825
 
814
- function recentCwdKeys(e) {
815
- const options = $('cwd-recent').hidden ? [] : [...$('cwd-recent').children];
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 (!recentView.open) showRecentCwds();
819
- const count = $('cwd-recent').children.length;
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
- renderRecentCwds();
823
- $(`cwd-recent-${recentView.active}`).scrollIntoView?.({ block: 'nearest' });
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
- pickRecentCwd(options[recentView.active].textContent);
839
+ pickRecent(field, options[recentView.active].textContent);
827
840
  } else if (e.key === 'Escape' && options.length) {
828
841
  e.preventDefault();
829
- hideRecentCwds();
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
- rememberCwd(session.cwd);
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 Set(), announced: new Set() };
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 { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent: cloneParent() });
3095
- githubView.started.add(session.id);
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)) toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
3206
- else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
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
- this.term.write(msg.data, () => { this.sent = { cols: 0, rows: 0 }; this.refit(); });
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
- return splitMode(box.width, box.height);
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
- new ResizeObserver(layoutPanes).observe($('terminal-panel'));
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
- $('cwd').addEventListener('focus', () => showRecentCwds());
5540
- $('cwd').addEventListener('click', () => { if (!recentView.open) showRecentCwds(); });
5541
- $('cwd').addEventListener('input', () => showRecentCwds(true));
5542
- $('cwd').addEventListener('blur', hideRecentCwds);
5543
- $('cwd').addEventListener('keydown', recentCwdKeys);
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
- new ResizeObserver(publishTopbarHeight).observe(topbar);
5574
- addEventListener('scroll', publishTopbarHeight, { passive: true });
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); }
@@ -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); }
@@ -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 0; border-radius: 0; }
572
+ .terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
549
573
  }
550
574
 
551
575
  .models {
@@ -558,7 +582,8 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
558
582
  .models-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
559
583
  .models-heading { flex: 1; min-width: 0; }
560
584
  .models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
561
- .models-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
585
+ /* Content supplies the basis for auto-height dialogs; a zero basis collapses in Safari. */
586
+ .models-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 14px 14px; }
562
587
  .remote-access { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
563
588
  .remote-body { padding: 4px 20px 20px; font-size: 0.9rem; }
564
589
  .remote-body > p { color: var(--muted); line-height: 1.6; }
@@ -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 0; border-radius: 0; }
996
+ .terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
972
997
  .session-grid { grid-template-columns: 1fr; }
973
998
  .cwd { flex-basis: 100%; }
974
999
  .launch-tools { flex-basis: 100%; justify-content: stretch; }
@@ -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
+ }