Skip to content

Commit a696af5

Browse files
authored
Merge pull request #2608 from Ayush7614/feat/firefox-global-keyboard-shortcuts
fix(firefox): implement documented global keyboard shortcuts
2 parents b312cdf + b0fee21 commit a696af5

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

src/firefox/src/ui/sidepanel.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9311,6 +9311,66 @@ async function sendToBackground(action, data = {}) {
93119311
return response;
93129312
}
93139313

9314+
async function handleGlobalKeydown(e) {
9315+
if (e.defaultPrevented) return;
9316+
9317+
// Don't steal shortcuts from other input elements (e.g. schedule form fields)
9318+
const tag = e.target?.tagName;
9319+
const isOtherFormField = e.target !== inputEl && (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT');
9320+
9321+
if (e.key === 'Escape') {
9322+
// IME Escape cancels a composition candidate — never abort the run for that.
9323+
if (e.isComposing) return;
9324+
const slashMenuOpen = !!slashCommandMenuEl && !slashCommandMenuEl.classList.contains('hidden');
9325+
if (slashMenuOpen) return;
9326+
// Provider/language pickers close on Escape in bubble/target handlers; do not
9327+
// abort the active run while those listboxes are open.
9328+
const providerPickerOpen = !!providerPickerMenu && !providerPickerMenu.classList.contains('hidden');
9329+
if (providerPickerOpen) return;
9330+
const languagePickerOpen = !!languagePickerMenu && !languagePickerMenu.classList.contains('hidden');
9331+
if (languagePickerOpen) return;
9332+
if (isProcessing) {
9333+
e.preventDefault();
9334+
abortRun();
9335+
return;
9336+
}
9337+
if (isOtherFormField) return;
9338+
}
9339+
9340+
if (isOtherFormField) return;
9341+
// AltGr (Ctrl+Alt) and Option chords type international characters — never
9342+
// treat them as Ctrl/Cmd shortcuts.
9343+
if (e.altKey || e.getModifierState?.('AltGraph')) return;
9344+
9345+
const mod = e.ctrlKey || e.metaKey;
9346+
9347+
// Ctrl+/ (Cmd+/ on Mac): focus input
9348+
if (mod && e.key === '/') {
9349+
e.preventDefault();
9350+
inputEl.focus();
9351+
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
9352+
return;
9353+
}
9354+
// Ctrl+Shift+A: switch to Ask mode (blocked while agent is running)
9355+
if (mod && e.shiftKey && e.key === 'A' && !isProcessing) {
9356+
e.preventDefault();
9357+
setMode('ask');
9358+
return;
9359+
}
9360+
// Ctrl+Shift+X: switch to Act mode (blocked while agent is running)
9361+
if (mod && e.shiftKey && e.key === 'X' && !isProcessing) {
9362+
e.preventDefault();
9363+
await ensureActMode();
9364+
return;
9365+
}
9366+
// Ctrl+Shift+D: switch to Dev mode (blocked while agent is running)
9367+
if (mod && e.shiftKey && e.key === 'D' && !isProcessing) {
9368+
e.preventDefault();
9369+
await ensureDevMode();
9370+
return;
9371+
}
9372+
}
9373+
93149374
// --- Mode Toggle ---
93159375

93169376
function positionModeHighlight(btn, { instant = false } = {}) {
@@ -9823,6 +9883,8 @@ if (attachBtn && fileAttachInput) {
98239883

98249884
sendBtn.addEventListener('click', sendMessage);
98259885

9886+
document.addEventListener('keydown', handleGlobalKeydown, true);
9887+
98269888
queuedMessagesEl?.addEventListener('click', (e) => {
98279889
const btn = e.target.closest('button[data-queue-action][data-queue-id]');
98289890
if (!btn) return;

test/run.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16734,6 +16734,41 @@ test('chrome sidepanel Escape abort honors slash autocomplete dismissal', () =>
1673416734
assert.equal(abortCall < recordingEscapeCall, true, 'chrome: agent-run Escape abort should take precedence over recording stop');
1673516735
});
1673616736

16737+
test('firefox sidepanel implements documented global keyboard shortcuts', () => {
16738+
const panel = fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/sidepanel.js'), 'utf8');
16739+
const locale = fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/locales/en.js'), 'utf8');
16740+
16741+
const globalHandlerStart = panel.indexOf('async function handleGlobalKeydown(e)');
16742+
const defaultPreventedGuard = panel.indexOf('if (e.defaultPrevented) return;', globalHandlerStart);
16743+
const abortCall = panel.indexOf('abortRun();', globalHandlerStart);
16744+
assert.notEqual(globalHandlerStart, -1, 'firefox: global keydown handler missing');
16745+
assert.notEqual(defaultPreventedGuard, -1, 'firefox: global keydown handler should honor consumed key events');
16746+
assert.notEqual(abortCall, -1, 'firefox: Escape abort shortcut missing');
16747+
assert.equal(defaultPreventedGuard < abortCall, true, 'firefox: consumed slash-menu Escape should not reach abortRun');
16748+
assert.match(panel, /document\.addEventListener\('keydown', handleGlobalKeydown, true\)/, 'firefox: shortcuts should run in capture phase');
16749+
assert.match(panel, /mod && e\.key === '\/'/, 'firefox: Ctrl/Cmd+/ focus shortcut missing');
16750+
assert.match(panel, /mod && e\.shiftKey && e\.key === 'A'/, 'firefox: Ask mode shortcut missing');
16751+
assert.match(panel, /mod && e\.shiftKey && e\.key === 'X'/, 'firefox: Act mode shortcut missing');
16752+
assert.match(panel, /mod && e\.shiftKey && e\.key === 'D'/, 'firefox: Dev mode shortcut missing');
16753+
assert.match(panel, /await ensureActMode\(\)/, 'firefox: Act shortcut should call ensureActMode');
16754+
assert.match(panel, /await ensureDevMode\(\)/, 'firefox: Dev shortcut should call ensureDevMode');
16755+
// Review P1s: AltGr / IME / open pickers must not steal Escape or slash entry
16756+
assert.match(panel, /e\.altKey \|\| e\.getModifierState\?\.\('AltGraph'\)/, 'firefox: AltGr/Option chords must not trigger Ctrl shortcuts');
16757+
assert.match(panel, /if \(e\.isComposing\) return;/, 'firefox: IME Escape must not abort the active run');
16758+
assert.match(panel, /providerPickerMenu && !providerPickerMenu\.classList\.contains\('hidden'\)/, 'firefox: open provider picker Escape must not abort');
16759+
assert.match(panel, /languagePickerMenu && !languagePickerMenu\.classList\.contains\('hidden'\)/, 'firefox: open language picker Escape must not abort');
16760+
const composingGuard = panel.indexOf('if (e.isComposing) return;', globalHandlerStart);
16761+
const providerPickerGuard = panel.indexOf("providerPickerMenu && !providerPickerMenu.classList.contains('hidden')", globalHandlerStart);
16762+
const languagePickerGuard = panel.indexOf("languagePickerMenu && !languagePickerMenu.classList.contains('hidden')", globalHandlerStart);
16763+
assert.equal(composingGuard !== -1 && composingGuard < abortCall, true, 'firefox: IME guard must precede abortRun');
16764+
assert.equal(providerPickerGuard !== -1 && providerPickerGuard < abortCall, true, 'firefox: provider picker guard must precede abortRun');
16765+
assert.equal(languagePickerGuard !== -1 && languagePickerGuard < abortCall, true, 'firefox: language picker guard must precede abortRun');
16766+
16767+
for (const shortcut of ['Ctrl/Cmd+/', 'Ctrl/Cmd+Shift+A', 'Ctrl/Cmd+Shift+X', 'Ctrl/Cmd+Shift+D', 'Escape']) {
16768+
assert.match(locale, new RegExp(escapeRegExpLiteral(shortcut)), `firefox: /help should mention ${shortcut}`);
16769+
}
16770+
});
16771+
1673716772
test('chrome double Escape stops active recordings from sidepanel and content pages', () => {
1673816773
const panel = fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/sidepanel.js'), 'utf8');
1673916774
const content = fs.readFileSync(path.join(ROOT, 'src/chrome/src/content/content.js'), 'utf8');

0 commit comments

Comments
 (0)