@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/README.md +17 -0
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/install-channels.mjs +1 -0
- package/src/manager/multiplexer-paths.mjs +81 -0
- package/src/manager/multiplexers.mjs +364 -0
- package/src/manager/providers.mjs +31 -4
- package/src/manager/server.mjs +7 -0
- package/src/manager/session-manager.mjs +207 -16
- package/src/manager/uninstall.mjs +29 -2
- package/src/manager/versions.mjs +12 -0
- package/web/app.js +309 -8
- package/web/index.html +12 -0
- package/web/styles.css +30 -2
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
|
-
|
|
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
|
-
|
|
818
|
-
box.
|
|
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
|
-
|
|
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%; }
|