@oddessentials/agent-guild 0.22.0 → 0.24.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/web/app.js CHANGED
@@ -16,6 +16,7 @@ const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
16
16
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
17
17
  const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
18
18
  const SOUND_KEY = 'agentGuild.sound';
19
+ const VOICE_KEY = 'agentGuild.voice';
19
20
  const NOTES_KEY = 'agentGuild.notes';
20
21
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
21
22
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
@@ -297,8 +298,15 @@ function placeAppearanceMenu() {
297
298
  const menu = $('appearance-menu');
298
299
  if (!menu.matches(':popover-open')) return;
299
300
  const box = $('appearance').getBoundingClientRect();
300
- menu.style.top = `${Math.round(box.bottom + 6)}px`;
301
+ const top = Math.round(box.bottom + 6);
302
+ menu.style.top = `${top}px`;
303
+ menu.style.maxHeight = `${innerHeight - top - 8}px`;
304
+ menu.style.left = 'auto';
301
305
  menu.style.right = `${Math.max(8, Math.round(innerWidth - box.right))}px`;
306
+ if (menu.getBoundingClientRect().left < 8) {
307
+ menu.style.right = 'auto';
308
+ menu.style.left = `${Math.max(8, Math.round(box.left))}px`;
309
+ }
302
310
  }
303
311
 
304
312
  // ---- notes ----------------------------------------------------------------
@@ -751,6 +759,8 @@ let dealt = false;
751
759
 
752
760
  function renderProviders() {
753
761
  const list = $('providers');
762
+ const focused = document.activeElement;
763
+ const focusKey = focused?.dataset?.muxFocus;
754
764
  const tpl = $('provider-template');
755
765
  list.replaceChildren(...state.providers.map((provider) => {
756
766
  const node = tpl.content.firstElementChild.cloneNode(true);
@@ -790,11 +800,20 @@ function renderProviders() {
790
800
  renderVendorLinks(node, provider);
791
801
  renderAccounts(node, provider);
792
802
  renderShells(node, provider);
803
+ renderMultiplexers(node, provider);
793
804
  renderUsage(node, provider);
794
805
  renderReportingSetup(node, provider);
795
806
  renderModelStats(node, provider);
796
807
  return node;
797
808
  }));
809
+ if (focusKey) {
810
+ const controls = [...list.querySelectorAll('[data-mux-focus]')];
811
+ const fallback = focusKey.split(':').slice(0, 2).join(':') + ':copies';
812
+ const replacement = controls.find((node) => node.dataset.muxFocus === focusKey && !node.disabled)
813
+ || controls.find((node) => node.dataset.muxFocus === fallback && !node.closest('[hidden]'))
814
+ || controls.find((node) => node.dataset.muxFocus === focusKey.split(':')[0] + ':multiplexers');
815
+ replacement?.focus({ preventScroll: true });
816
+ }
798
817
  if (!dealt && state.providers.length) {
799
818
  dealt = true;
800
819
  if (!reducedMotion.matches) list.classList.add('deal');
@@ -803,19 +822,21 @@ function renderProviders() {
803
822
 
804
823
  const openCopies = new Set();
805
824
 
806
- function renderCopies(box, provider, card) {
825
+ function renderCopies(box, provider, card, actions = null) {
807
826
  const installs = provider.installs || [];
808
827
  const warnings = provider.warnings || [];
809
- box.hidden = warnings.length === 0 && !installs.some((i) => i.uninstall);
828
+ box.hidden = actions ? installs.length === 0 : warnings.length === 0 && !installs.some((i) => i.uninstall);
810
829
  if (box.hidden) return;
811
830
  box.classList.toggle('warned', warnings.length > 0);
812
831
  const inUse = installs.some((i) => i.active);
813
832
  const older = installs.some((i) => i.newer);
814
- box.querySelector('summary').textContent = !inUse
833
+ box.querySelector('summary').textContent = installs.every((i) => i.partial) ? 'Incomplete installation' : !inUse
815
834
  ? 'A copy exists off PATH'
816
835
  : installs.length === 1 ? 'Installation' : `${installs.length} copies installed${older ? ' · older copy in use' : ''}`;
817
- box.open = openCopies.has(provider.id);
818
- box.addEventListener('toggle', () => (box.open ? openCopies.add(provider.id) : openCopies.delete(provider.id)));
836
+ const key = actions?.key || provider.id;
837
+ box.open = openCopies.has(key);
838
+ box.addEventListener('toggle', () => (box.open ? openCopies.add(key) : openCopies.delete(key)));
839
+ if (actions) box.querySelector('summary').dataset.muxFocus = key + ':copies';
819
840
  const line = (className, ...content) => {
820
841
  const span = document.createElement('span');
821
842
  span.className = className;
@@ -834,6 +855,17 @@ function renderCopies(box, provider, card) {
834
855
  const where = line('copy-path', install.displayPath);
835
856
  where.title = install.path;
836
857
  item.append(head, where);
858
+ if (actions && install.updateCommand && (install.updateAvailable || install.versionStatus === 'failed')) {
859
+ const update = document.createElement('button');
860
+ update.type = 'button';
861
+ update.className = 'btn copy-update';
862
+ update.textContent = 'Update';
863
+ update.title = `Run ${install.updateCommand}`;
864
+ update.disabled = actions.busy;
865
+ update.dataset.muxFocus = key + ':update:' + install.path;
866
+ update.addEventListener('click', () => actions.update(install));
867
+ head.append(update);
868
+ } else if (actions && install.updateGuidance) item.append(line('copy-remove', install.updateGuidance));
837
869
  if (install.uninstall) {
838
870
  const button = document.createElement('button');
839
871
  button.type = 'button';
@@ -843,7 +875,11 @@ function renderCopies(box, provider, card) {
843
875
  button.title = install.uninstall.command
844
876
  ? [`Runs ${install.uninstall.command}`, ...install.uninstall.remove.map((p) => `then deletes ${p}`)].join('\n')
845
877
  : ['Deletes', ...install.uninstall.remove].join('\n');
846
- button.addEventListener('click', () => uninstallCopy(provider, card, install));
878
+ if (actions) {
879
+ button.disabled = actions.busy;
880
+ button.dataset.muxFocus = key + ':uninstall:' + install.path;
881
+ }
882
+ button.addEventListener('click', () => actions ? actions.uninstall(install) : uninstallCopy(provider, card, install));
847
883
  head.append(button);
848
884
  } else {
849
885
  item.append(line('copy-remove', install.uninstallGuidance));
@@ -852,6 +888,102 @@ function renderCopies(box, provider, card) {
852
888
  }));
853
889
  }
854
890
 
891
+ const openMultiplexers = new Set();
892
+
893
+ function renderMultiplexers(card, provider) {
894
+ const host = card.querySelector('.multiplexers');
895
+ const tools = provider.multiplexers || [];
896
+ host.hidden = tools.length === 0;
897
+ if (host.hidden) return;
898
+ host.open = openMultiplexers.has(provider.id);
899
+ host.querySelector('summary').dataset.muxFocus = provider.id + ':multiplexers';
900
+ host.addEventListener('toggle', () => host.open ? openMultiplexers.add(provider.id) : openMultiplexers.delete(provider.id));
901
+ const refresh = host.querySelector('.multiplexer-refresh');
902
+ refresh.dataset.muxFocus = provider.id + ':refresh';
903
+ refresh.addEventListener('click', async () => {
904
+ refresh.disabled = true;
905
+ try {
906
+ const result = await api('POST', '/providers/reload', {});
907
+ state.providers = result.providers;
908
+ renderProviders();
909
+ } catch (err) {
910
+ if (err instanceof AuthError) showAuth(err.message);
911
+ else toast(err.message, 10000);
912
+ } finally { refresh.disabled = false; }
913
+ });
914
+ host.querySelector('.multiplexer-list').replaceChildren(...tools.map((tool) => {
915
+ const row = document.createElement('section');
916
+ row.className = 'multiplexer-row';
917
+ const title = document.createElement('strong');
918
+ title.textContent = tool.tool;
919
+ row.append(title);
920
+ const key = provider.id + ':' + tool.id;
921
+ if (tool.installable) {
922
+ const button = document.createElement('button');
923
+ button.type = 'button';
924
+ button.className = 'btn primary';
925
+ button.textContent = 'Install';
926
+ button.disabled = tool.busy;
927
+ button.title = tool.installCommand || '';
928
+ button.dataset.muxFocus = key + ':install';
929
+ button.addEventListener('click', () => manageMultiplexer(provider, tool, 'install'));
930
+ row.append(button);
931
+ }
932
+ const note = document.createElement('p');
933
+ note.className = 'multiplexer-note';
934
+ note.textContent = [
935
+ tool.busy ? 'Installation operation in progress…' : tool.guidance,
936
+ tool.pendingCards ? `${tool.pendingCards} detached ${tool.tool} card(s) are waiting to be restored. Check the installation or remove finished cards, then Refresh.` : '',
937
+ installNote(tool),
938
+ ].filter(Boolean).join(' ');
939
+ if (note.textContent) row.append(note);
940
+ const copies = document.createElement('details');
941
+ copies.className = 'copies';
942
+ copies.append(document.createElement('summary'), document.createElement('ul'));
943
+ row.append(copies);
944
+ renderCopies(copies, { ...tool, id: key, warnings: [] }, card, {
945
+ key, busy: tool.busy,
946
+ update: (copy) => manageMultiplexer(provider, tool, 'update', copy),
947
+ uninstall: (copy) => {
948
+ const plan = copy.uninstall;
949
+ const lines = [
950
+ `Uninstall ${tool.tool} from ${copy.displayPath}?`,
951
+ plan.command && `Runs: ${plan.command}`,
952
+ ...plan.remove.map((p) => `Removes: ${p}`),
953
+ plan.pathEntries && 'Removes only this installation’s entries from your user PATH.',
954
+ 'Your settings and session data are kept.',
955
+ ].filter(Boolean);
956
+ if (confirm(lines.join('\n'))) return manageMultiplexer(provider, tool, 'uninstall', copy);
957
+ },
958
+ });
959
+ return row;
960
+ }));
961
+ }
962
+
963
+ async function manageMultiplexer(provider, tool, kind, copy = null, force = false) {
964
+ // Busy state also comes from the manager, covering other tabs and refreshes.
965
+ tool.busy = true;
966
+ renderProviders();
967
+ try {
968
+ const { session } = await api('POST', `/providers/${provider.id}/multiplexers/${tool.id}/${kind}`, { path: copy?.path, force });
969
+ upsertSession(session);
970
+ openPanel(session.id);
971
+ } catch (err) {
972
+ if (err instanceof AuthError) return showAuth(err.message);
973
+ if (err.code === 'multiplexer_in_use' && !force) {
974
+ const waiting = err.pending ? ` ${err.pending} detached cards are also waiting for the tool.` : '';
975
+ if (confirm(`${err.message}${waiting} Continue anyway?`)) return manageMultiplexer(provider, tool, kind, copy, true);
976
+ } else toast(err.message, 10000);
977
+ } finally {
978
+ // A successful operation stays busy until the manager sends fresh state.
979
+ try {
980
+ const result = await api('GET', '/providers');
981
+ state.providers = result.providers;
982
+ } catch { tool.busy = false; }
983
+ renderProviders();
984
+ }
985
+ }
986
+
855
987
  function renderHint(hint, provider) {
856
988
  let text = '';
857
989
  if (!provider.available) text = provider.installable ? '' : provider.install || `${provider.command} was not found on PATH.`;
@@ -1879,7 +2011,7 @@ function renderChangelog() {
1879
2011
  }
1880
2012
 
1881
2013
  const CHANNEL_LABELS = {
1882
- npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
2014
+ npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', system: 'system package', legacy: 'legacy install', unknown: 'unknown install',
1883
2015
  };
1884
2016
 
1885
2017
  function installNote(provider) {
@@ -3366,6 +3498,7 @@ class TerminalView {
3366
3498
  this.term.write(msg.data);
3367
3499
  break;
3368
3500
  case 'exit':
3501
+ if (dictation?.id === this.id) stopDictation();
3369
3502
  this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
3370
3503
  break;
3371
3504
  default:
@@ -3460,6 +3593,7 @@ class TerminalView {
3460
3593
 
3461
3594
  function openPanel(id) {
3462
3595
  if (!state.sessions.has(id)) return;
3596
+ if (dictation && dictation.id !== id) stopDictation();
3463
3597
  if (state.activeId && state.activeId !== id) state.views.get(state.activeId)?.unmount();
3464
3598
  state.activeId = id;
3465
3599
  let view = state.views.get(id);
@@ -3470,12 +3604,15 @@ function openPanel(id) {
3470
3604
  }
3471
3605
 
3472
3606
  function closePanel() {
3607
+ stopDictation();
3473
3608
  if (state.activeId) state.views.get(state.activeId)?.unmount();
3474
3609
  state.activeId = null;
3475
3610
  $('terminal-panel').hidden = true;
3611
+ renderVoice();
3476
3612
  }
3477
3613
 
3478
3614
  function updatePanel() {
3615
+ renderVoice();
3479
3616
  const s = state.sessions.get(state.activeId);
3480
3617
  if (!s) return;
3481
3618
  paintProviderIcon($('panel-icon'), s.provider);
@@ -3488,6 +3625,157 @@ function updatePanel() {
3488
3625
  stop.textContent = s.status !== 'running' ? 'Remove' : s.multiplexer ? 'Detach' : 'Stop';
3489
3626
  }
3490
3627
 
3628
+ // ---- voice input ----------------------------------------------------------
3629
+
3630
+ const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
3631
+ const VOICE_RETRY_DELAYS = [250, 500, 1000];
3632
+ let dictation = null;
3633
+
3634
+ function voiceOn() {
3635
+ return Boolean(Recognition) && load(VOICE_KEY) === 'on';
3636
+ }
3637
+
3638
+ function renderVoice() {
3639
+ if (dictation) keepDictating(dictation);
3640
+ $('voice-choice').hidden = !Recognition;
3641
+ $('voice').checked = voiceOn();
3642
+ $('panel-voice').hidden = !voiceOn() || state.sessions.get(state.activeId)?.status !== 'running';
3643
+ $('panel-voice').setAttribute('aria-pressed', String(Boolean(dictation)));
3644
+ }
3645
+
3646
+ function changeVoice(input) {
3647
+ save(VOICE_KEY, input.checked ? 'on' : null);
3648
+ renderVoice();
3649
+ }
3650
+
3651
+ /** Dictated words as terminal input: one line of text, no control characters, so nothing is ever submitted. */
3652
+ function dictatedText(text, first) {
3653
+ const clean = text.replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ').replace(/\s+/g, ' ').trim();
3654
+ return clean && (first ? clean : ` ${clean}`);
3655
+ }
3656
+
3657
+ function canDictate(current) {
3658
+ const session = state.sessions.get(current.id);
3659
+ return voiceOn() && state.activeId === current.id && session?.status === 'running'
3660
+ && session.startedAt === current.startedAt && state.views.has(current.id)
3661
+ && !state.pageAway && !state.stopping && document.visibilityState === 'visible'
3662
+ && !$('app').hidden && !$('terminal-panel').hidden;
3663
+ }
3664
+
3665
+ /** Every async continuation belongs to one explicit click and one run of the session. */
3666
+ function keepDictating(current) {
3667
+ if (dictation !== current) return false;
3668
+ if (canDictate(current)) return true;
3669
+ stopDictation();
3670
+ return false;
3671
+ }
3672
+
3673
+ async function startDictation() {
3674
+ const id = state.activeId;
3675
+ const session = state.sessions.get(id);
3676
+ if (!session || dictation) return;
3677
+ const current = {
3678
+ id, startedAt: session.startedAt, lang: navigator.language || 'en-US',
3679
+ android: /Android/i.test(navigator.userAgent || ''), processLocally: false,
3680
+ rec: null, timer: null, first: true, emptyRetries: 0,
3681
+ };
3682
+ if (!canDictate(current)) return;
3683
+ // Register before awaiting availability so another click or leaving the terminal cancels startup too.
3684
+ dictation = current;
3685
+ renderVoice();
3686
+ state.views.get(id)?.term.focus();
3687
+ try {
3688
+ // Browsers without this API start in the click handler, without an unnecessary await.
3689
+ if (typeof Recognition.available === 'function') {
3690
+ current.processLocally = await Recognition.available({ langs: [current.lang], processLocally: true }) === 'available';
3691
+ }
3692
+ } catch { /* cloud recognition */ }
3693
+ listenForDictation(current);
3694
+ }
3695
+
3696
+ function listenForDictation(current) {
3697
+ if (!keepDictating(current)) return;
3698
+ let rec;
3699
+ try {
3700
+ rec = new Recognition();
3701
+ current.rec = rec;
3702
+ rec.lang = current.lang;
3703
+ rec.continuous = !current.android;
3704
+ rec.interimResults = true;
3705
+ if (current.processLocally) rec.processLocally = true;
3706
+ } catch {
3707
+ stopDictation();
3708
+ toast('Voice input could not start.');
3709
+ return;
3710
+ }
3711
+ // A new recognizer per Android phrase avoids cumulative transcripts from continuous mode.
3712
+ let committed = 0;
3713
+ let heard = false;
3714
+ const preview = $('voice-preview');
3715
+ rec.onresult = (e) => {
3716
+ if (current.rec !== rec || !keepDictating(current)) return;
3717
+ let interim = '';
3718
+ for (let i = Math.max(e.resultIndex, committed); i < e.results.length; i += 1) {
3719
+ const result = e.results[i];
3720
+ if (!result.isFinal) { interim += result[0].transcript; continue; }
3721
+ committed = i + 1;
3722
+ const text = dictatedText(result[0].transcript, current.first);
3723
+ if (!text) continue;
3724
+ state.views.get(current.id)?.term.paste(text);
3725
+ current.first = false;
3726
+ current.emptyRetries = 0;
3727
+ heard = true;
3728
+ }
3729
+ preview.textContent = interim.trim();
3730
+ preview.hidden = !preview.textContent;
3731
+ };
3732
+ rec.onerror = (e) => {
3733
+ if (current.rec !== rec || !keepDictating(current)) return;
3734
+ if (current.android && e.error === 'no-speech') return; // bounded retry after end
3735
+ stopDictation();
3736
+ if (e.error === 'not-allowed' || e.error === 'service-not-allowed') toast('Voice input needs permission to use the microphone.');
3737
+ else if (e.error === 'network') toast('Voice input could not reach the speech service. Check your connection.');
3738
+ else if (e.error === 'audio-capture') toast('No microphone was found.');
3739
+ else if (e.error === 'language-not-supported') toast(`Voice input does not support ${current.lang}.`);
3740
+ else if (e.error !== 'aborted' && e.error !== 'no-speech') toast('Voice input stopped. Press Dictate to try again.');
3741
+ };
3742
+ rec.onend = () => {
3743
+ if (current.rec !== rec || !keepDictating(current)) return;
3744
+ current.rec = null;
3745
+ preview.hidden = true;
3746
+ preview.textContent = '';
3747
+ if (!current.android) return stopDictation();
3748
+ if (!heard && current.emptyRetries >= VOICE_RETRY_DELAYS.length) {
3749
+ stopDictation();
3750
+ toast('Voice input stopped after repeated silence. Press Dictate to try again.');
3751
+ return;
3752
+ }
3753
+ const delay = heard ? VOICE_RETRY_DELAYS[0] : VOICE_RETRY_DELAYS[current.emptyRetries++];
3754
+ current.timer = setTimeout(() => {
3755
+ current.timer = null;
3756
+ listenForDictation(current);
3757
+ }, delay);
3758
+ };
3759
+ try {
3760
+ rec.start();
3761
+ } catch {
3762
+ if (current.rec !== rec || !keepDictating(current)) return;
3763
+ stopDictation();
3764
+ toast('Voice input could not start.');
3765
+ }
3766
+ }
3767
+
3768
+ function stopDictation() {
3769
+ const current = dictation;
3770
+ if (!current) return;
3771
+ dictation = null;
3772
+ clearTimeout(current.timer);
3773
+ try { current.rec?.abort(); } catch { /* already ended */ }
3774
+ $('voice-preview').hidden = true;
3775
+ $('voice-preview').textContent = '';
3776
+ $('panel-voice').setAttribute('aria-pressed', 'false');
3777
+ }
3778
+
3491
3779
  // ---- stopping the manager -------------------------------------------------
3492
3780
 
3493
3781
  /**
@@ -3743,6 +4031,7 @@ let newsTimer;
3743
4031
 
3744
4032
  function showAuth(message = '') {
3745
4033
  managerLoss.cancel();
4034
+ closePanel();
3746
4035
  closeModels();
3747
4036
  closeNews();
3748
4037
  closeChangelog();
@@ -3794,6 +4083,13 @@ $('auth-form').addEventListener('submit', (e) => {
3794
4083
  boot();
3795
4084
  });
3796
4085
  $('panel-close').addEventListener('click', closePanel);
4086
+ $('panel-voice').addEventListener('click', () => {
4087
+ if (dictation) {
4088
+ const { id } = dictation;
4089
+ stopDictation();
4090
+ state.views.get(id)?.term.focus();
4091
+ } else startDictation();
4092
+ });
3797
4093
  $('models-close').addEventListener('click', closeModels);
3798
4094
  $('history-close').addEventListener('click', closeHistory);
3799
4095
  $('history').addEventListener('click', (e) => { if (e.target === $('history')) closeHistory(); });
@@ -3886,10 +4182,12 @@ $('news').addEventListener('close', () => {
3886
4182
  newsView.opener = null;
3887
4183
  });
3888
4184
  document.addEventListener('visibilitychange', () => {
4185
+ if (document.visibilityState !== 'visible') stopDictation();
3889
4186
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
3890
4187
  });
3891
4188
  addEventListener('pagehide', () => {
3892
4189
  state.pageAway = true;
4190
+ stopDictation();
3893
4191
  managerLoss.cancel();
3894
4192
  state.eventsSocket?.close();
3895
4193
  });
@@ -3917,6 +4215,7 @@ $('changelog-restart').addEventListener('click', () => {
3917
4215
  addEventListener('storage', (e) => {
3918
4216
  if (e.key === CHANGELOG_SEEN_KEY) renderVersion();
3919
4217
  else if (e.key === SOUND_KEY) { $('sound').checked = soundOn(); prepareSounds(); }
4218
+ else if (e.key === VOICE_KEY) renderVoice();
3920
4219
  // Another tab reordered the cards.
3921
4220
  else if (e.key === SESSION_ORDER_KEY && !drag) arrange(parseOrder(e.newValue));
3922
4221
  });
@@ -3963,6 +4262,7 @@ $('appearance-menu').addEventListener('change', (e) => {
3963
4262
  if (e.target.name === 'skin') changeSkin(e.target);
3964
4263
  else if (e.target.name === 'theme') changeTheme(e.target);
3965
4264
  else if (e.target.name === 'sound') changeSound(e.target);
4265
+ else if (e.target.name === 'voice') changeVoice(e.target);
3966
4266
  });
3967
4267
  $('appearance-menu').addEventListener('toggle', (e) => {
3968
4268
  if (e.newState !== 'open') return;
@@ -3997,6 +4297,7 @@ $('providers').addEventListener('pointerout', (e) => {
3997
4297
  applyTheme(currentTheme());
3998
4298
  renderSkinChoices();
3999
4299
  $('sound').checked = soundOn();
4300
+ renderVoice();
4000
4301
  // Follow the system setting until the user picks a theme.
4001
4302
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
4002
4303
  if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
package/web/index.html CHANGED
@@ -40,6 +40,11 @@
40
40
  <legend>Sound</legend>
41
41
  <label class="choice" title="Plays when the session manager stops, remains unavailable after a brief recovery check, or a new Agent Guild update is discovered. Click or type on this page to allow playback."><input id="sound" type="checkbox" name="sound"><span class="sound-alerts">Alert sounds</span></label>
42
42
  </fieldset>
43
+ <fieldset id="voice-choice" hidden>
44
+ <legend>Voice</legend>
45
+ <label class="choice"><input id="voice" type="checkbox" name="voice"><span class="voice-input">Voice input</span></label>
46
+ <p class="voice-note">Adds a Dictate button to terminals. Your browser may send the audio to its maker, such as Google or Microsoft, to turn it into text. Nothing is heard until you press Dictate, and Enter is never pressed for you.</p>
47
+ </fieldset>
43
48
  </div>
44
49
  <button id="notes-open" class="btn notes-open" type="button" aria-haspopup="dialog">Notes</button>
45
50
  <button id="restart-manager" class="btn restart-manager" type="button" hidden>Restart manager</button>
@@ -119,11 +124,13 @@
119
124
  <div id="panel-agents" class="agents"></div>
120
125
  </div>
121
126
  <div class="panel-actions">
127
+ <button id="panel-voice" class="btn voice" type="button" hidden aria-pressed="false" title="Speak into this terminal. Review the text, then press Enter yourself.">Dictate</button>
122
128
  <button id="panel-stop" class="btn danger" type="button">Stop</button>
123
129
  <button id="panel-close" class="btn" type="button" title="Hide the terminal. The session keeps running.">Hide</button>
124
130
  </div>
125
131
  </div>
126
132
  <div id="terminal-host" class="terminal-host"></div>
133
+ <div id="voice-preview" class="voice-preview" hidden aria-live="polite"></div>
127
134
  </div>
128
135
 
129
136
  <dialog id="models" class="models" aria-labelledby="models-title">
@@ -297,6 +304,11 @@
297
304
  <summary></summary>
298
305
  <ul></ul>
299
306
  </details>
307
+ <details class="multiplexers" hidden>
308
+ <summary>Terminal multiplexers</summary>
309
+ <button class="btn multiplexer-refresh" type="button">Refresh</button>
310
+ <div class="multiplexer-list"></div>
311
+ </details>
300
312
  <div class="provider-actions">
301
313
  <button class="btn primary new" type="button">New</button>
302
314
  <button class="btn existing" type="button">Existing…</button>
package/web/styles.css CHANGED
@@ -22,6 +22,7 @@
22
22
  --font-mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
23
23
  --icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
24
24
  --icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
25
+ --icon-mic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='3' width='6' height='11' rx='3'/%3E%3Cpath d='M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21'/%3E%3C/svg%3E");
25
26
  --icon-sound: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 9.5h3.5L12 5.5v13l-4.5-4H4z'/%3E%3Cpath d='M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11'/%3E%3C/svg%3E");
26
27
  --icon-external:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
27
28
  --icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
@@ -344,13 +345,15 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
344
345
  }
345
346
  :root[data-theme="dark"] .appearance::before, .appearance-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
346
347
  .appearance-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
348
+ .appearance-menu .choice > .voice-input::before { mask-image: var(--icon-mic); }
349
+ .voice-note { margin: 0 8px 6px; max-width: 26ch; color: var(--muted); font-size: 0.75rem; line-height: 1.35; }
347
350
  .notes-open::before { mask-image: var(--icon-notes); }
348
351
  .appearance::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
349
352
  .appearance:has(+ :popover-open)::after { transform: rotate(-90deg); }
350
353
  .appearance-menu {
351
354
  position: fixed; inset: auto; margin: 0; min-width: 190px; padding: 6px; display: none;
352
355
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text);
353
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22); font-size: 0.85rem;
356
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22); font-size: 0.85rem; overflow-y: auto;
354
357
  }
355
358
  .appearance-menu:popover-open { display: grid; gap: 4px; animation: fade-in 0.12s var(--ease); }
356
359
  .appearance-menu fieldset { margin: 0; padding: 0; border: 0; display: grid; }
@@ -395,10 +398,24 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
395
398
  display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px;
396
399
  background: var(--surface); border-bottom: 1px solid var(--border); flex-wrap: wrap;
397
400
  }
398
- .panel-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
401
+ .panel-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1 1 16rem; }
399
402
  .panel-title .title { font-weight: 600; }
400
403
  .panel-title .sub { color: var(--muted); font-size: 0.78rem; }
401
404
  .panel-actions { display: flex; gap: 8px; }
405
+ .btn.voice { white-space: nowrap; }
406
+ .btn.voice::before {
407
+ content: ""; display: inline-block; width: 1em; height: 1em; margin-right: 0.35em; vertical-align: -0.15em;
408
+ background: currentColor; mask: var(--icon-mic) center / contain no-repeat;
409
+ }
410
+ .btn.voice[aria-pressed="true"] { color: var(--danger); border-color: var(--danger); }
411
+ .btn.voice[aria-pressed="true"]::before { animation: voice-pulse 1.2s ease-in-out infinite; }
412
+ @keyframes voice-pulse { 50% { opacity: 0.35; } }
413
+ .voice-preview {
414
+ position: absolute; left: 12px; right: 12px; bottom: 12px; max-height: 30%; overflow-y: auto; z-index: 1;
415
+ padding: 8px 12px; border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px);
416
+ background: var(--surface); color: var(--muted); font-size: clamp(0.8rem, 1.6vw, 0.95rem); font-style: italic;
417
+ overflow-wrap: anywhere; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); pointer-events: none;
418
+ }
402
419
  .terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
403
420
  .terminal-host > div { height: 100%; }
404
421
  @media (pointer: coarse) {
@@ -690,6 +707,7 @@ a.news-link:visited { color: var(--muted); }
690
707
  .model-name::before, .provider .copies summary::before, .appearance::after, .btn, .models, .news-panel, .models::backdrop, .news-panel::backdrop { transition: none; }
691
708
  .github-waiting::before, body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after { animation: none; }
692
709
  .btn:active:not(:disabled) { transform: none; }
710
+ .btn.voice[aria-pressed="true"]::before { animation: none; }
693
711
  .drag-handle, .session-grid::before, .session-grid.reordering::before,
694
712
  .session-grid > .session-card.dragging, .session-grid > .session-card.settling { transition: none; }
695
713
  }
@@ -718,3 +736,13 @@ a.news-link:visited { color: var(--muted); }
718
736
  .history-row .session-id { grid-row: 2; justify-self: start; }
719
737
  .history-form { flex-wrap: wrap; }
720
738
  }
739
+ /* Installation controls share each skin's card colors and button styles. */
740
+ .multiplexers { margin: .6rem 0; }
741
+ .multiplexers > summary { cursor: pointer; font-size: .85rem; }
742
+ .multiplexer-refresh { margin: .5rem 0; }
743
+ .multiplexer-row { padding: .65rem 0; border-top: 1px solid var(--border); }
744
+ .multiplexer-row > strong { margin-right: .6rem; }
745
+ .multiplexer-note { font-size: .8rem; line-height: 1.5; overflow-wrap: anywhere; margin: .4rem 0; }
746
+ .multiplexer-row .copies { margin-top: .4rem; }
747
+ .multiplexer-row .copy-head { flex-wrap: wrap; }
748
+ .multiplexer-row .copy-name { flex: 1 0 100%; }