diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 11dd516bb..7209ad702 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -39,6 +39,7 @@ for its available flags. | `/compact` | Force context compaction for the current conversation | | `/verbose` | Toggle verbose/compact tool display | | `/reset` | Clear the conversation and all per-conversation flags | +| `/print` | Open the current page's native print dialog | | `/screenshot [--full-page]` | Capture the visible tab, or the full scrollable page with `--full-page` (Chrome only) | | `/record [--full-screen] [--hide-recording-indicator] [--transcribe]` | Record the current tab, or a selected screen/window with `--full-screen` (Chrome only); add `--hide-recording-indicator` to hide the banner or `--transcribe` to save a transcript after stop | | `/export [--traces \| --config]` | Download version-stamped conversation Markdown, export the version-stamped tool chain with `--traces`, or export a Settings snapshot with `--config` | diff --git a/src/chrome/src/ui/locales/ar.js b/src/chrome/src/ui/locales/ar.js index 2190015c2..ed4a2abbd 100644 --- a/src/chrome/src/ui/locales/ar.js +++ b/src/chrome/src/ui/locales/ar.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'فتح مربع حوار الطباعة الأصلي للصفحة الحالية', + 'sp.print.error': 'تعذر فتح مربع حوار الطباعة: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'انقطع بث الاستجابة؛ تتم إعادة محاولة دور Ask هذا بدون بث.', 'sp.providers.no_setup_group': 'لا يتطلب إعدادًا', diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 65ae92caf..756c15ed5 100644 --- a/src/chrome/src/ui/locales/bn.js +++ b/src/chrome/src/ui/locales/bn.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'বর্তমান পৃষ্ঠার নেটিভ প্রিন্ট ডায়ালগ খুলুন', + 'sp.print.error': 'প্রিন্ট ডায়ালগ খোলা যায়নি: {msg}', 'sp.streaming.fallback': 'প্রতিক্রিয়া স্ট্রিম বাধাগ্রস্ত হয়েছে; স্ট্রিমিং ছাড়া এই Ask পালাটি আবার চেষ্টা করা হচ্ছে।', 'sp.providers.no_setup_group': "কোন সেটআপ প্রয়োজন", 'sp.providers.no_setup': "কোনো সেটআপ নেই", diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index 743a43be8..a7fa93cb4 100644 --- a/src/chrome/src/ui/locales/de.js +++ b/src/chrome/src/ui/locales/de.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Nativen Druckdialog der aktuellen Seite öffnen', + 'sp.print.error': 'Druckdialog konnte nicht geöffnet werden: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Der Antwortstream wurde unterbrochen; dieser Ask-Durchgang wird ohne Streaming erneut versucht.', 'sp.providers.no_setup_group': 'Keine Einrichtung erforderlich', diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index 19f0304d8..24d95b390 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -2,6 +2,8 @@ import apocalypseModeCopy from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Open the current page’s native print dialog', + 'sp.print.error': 'Could not open the print dialog: {msg}', 'sp.streaming.fallback': 'Response streaming was interrupted; retrying this Ask turn without streaming.', 'sp.providers.no_setup_group': 'No setup required', 'sp.providers.no_setup': 'No setup', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index 79c88890f..48fcc3289 100644 --- a/src/chrome/src/ui/locales/es.js +++ b/src/chrome/src/ui/locales/es.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Abrir el diálogo de impresión nativo de la página actual', + 'sp.print.error': 'No se pudo abrir el diálogo de impresión: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Se interrumpió la transmisión de la respuesta; reintentando este turno de Ask sin transmisión.', 'sp.providers.no_setup_group': 'Sin configuración', diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index 0f8fefc13..b6d92a81c 100644 --- a/src/chrome/src/ui/locales/fa.js +++ b/src/chrome/src/ui/locales/fa.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'باز کردن گفت‌وگوی چاپ بومی صفحهٔ فعلی', + 'sp.print.error': 'گفت‌وگوی چاپ باز نشد: {msg}', 'sp.streaming.fallback': 'جریان پاسخ قطع شد؛ این نوبت Ask بدون پخش جریانی دوباره امتحان می‌شود.', 'sp.providers.no_setup_group': "بدون نیاز به راه اندازی", 'sp.providers.no_setup': "بدون راه اندازی", diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index 0559db3a0..66c53e25f 100644 --- a/src/chrome/src/ui/locales/fr.js +++ b/src/chrome/src/ui/locales/fr.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Ouvrir la boîte de dialogue d’impression native de la page actuelle', + 'sp.print.error': 'Impossible d’ouvrir la boîte de dialogue d’impression : {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Le flux de réponse a été interrompu ; nouvelle tentative de ce tour Ask sans streaming.', 'sp.providers.no_setup_group': 'Sans configuration', diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index f75513826..1a0b38b1d 100644 --- a/src/chrome/src/ui/locales/he.js +++ b/src/chrome/src/ui/locales/he.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'פתיחת תיבת הדו־שיח המקורית להדפסת הדף הנוכחי', + 'sp.print.error': 'לא ניתן לפתוח את תיבת הדו־שיח להדפסה: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'הזרמת התשובה נקטעה; מתבצע ניסיון חוזר לתור Ask הזה ללא הזרמה.', 'sp.providers.no_setup_group': 'ללא הגדרה', diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index 2522f4a55..730ea4de2 100644 --- a/src/chrome/src/ui/locales/hi.js +++ b/src/chrome/src/ui/locales/hi.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'वर्तमान पृष्ठ का मूल प्रिंट संवाद खोलें', + 'sp.print.error': 'प्रिंट संवाद नहीं खुल सका: {msg}', 'sp.streaming.fallback': 'प्रतिक्रिया स्ट्रीम बाधित हुई; इस Ask टर्न को बिना स्ट्रीमिंग के फिर से आज़माया जा रहा है।', 'sp.providers.no_setup_group': "किसी सेटअप की आवश्यकता नहीं है", 'sp.providers.no_setup': "कोई सेटअप नहीं", diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index 7b04c40f8..1d853338b 100644 --- a/src/chrome/src/ui/locales/id.js +++ b/src/chrome/src/ui/locales/id.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Buka dialog cetak bawaan halaman saat ini', + 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Streaming respons terputus; mencoba kembali giliran Ask ini tanpa streaming.', 'sp.providers.no_setup_group': 'Tanpa penyiapan', diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index 63e3f975e..eca7acae0 100644 --- a/src/chrome/src/ui/locales/ja.js +++ b/src/chrome/src/ui/locales/ja.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': '現在のページのネイティブ印刷ダイアログを開く', + 'sp.print.error': '印刷ダイアログを開けませんでした: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': '応答ストリームが中断されました。この Ask ターンをストリーミングなしで再試行します。', 'sp.providers.no_setup_group': '設定不要', diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index 6afba1238..a51523c4c 100644 --- a/src/chrome/src/ui/locales/ko.js +++ b/src/chrome/src/ui/locales/ko.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': '현재 페이지의 기본 인쇄 대화상자 열기', + 'sp.print.error': '인쇄 대화상자를 열 수 없습니다: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': '응답 스트리밍이 중단되었습니다. 이 Ask 요청을 스트리밍 없이 다시 시도합니다.', 'sp.providers.no_setup_group': '설정 필요 없음', diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index 89e328073..1dcab821a 100644 --- a/src/chrome/src/ui/locales/ms.js +++ b/src/chrome/src/ui/locales/ms.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Buka dialog cetak asli halaman semasa', + 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Penstriman respons terganggu; mencuba semula giliran Ask ini tanpa penstriman.', 'sp.providers.no_setup_group': 'Tanpa persediaan', diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index cbed65105..8ad60766f 100644 --- a/src/chrome/src/ui/locales/nl.js +++ b/src/chrome/src/ui/locales/nl.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Het systeemeigen afdrukvenster van de huidige pagina openen', + 'sp.print.error': 'Het afdrukvenster kon niet worden geopend: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'De antwoordstream is onderbroken; deze Ask-beurt wordt opnieuw geprobeerd zonder streaming.', 'sp.providers.no_setup_group': 'Geen configuratie nodig', diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index c16b853a0..5e2cd1751 100644 --- a/src/chrome/src/ui/locales/pl.js +++ b/src/chrome/src/ui/locales/pl.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Otwórz natywne okno drukowania bieżącej strony', + 'sp.print.error': 'Nie udało się otworzyć okna drukowania: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Strumieniowanie odpowiedzi zostało przerwane; ponawiam tę turę Ask bez strumieniowania.', 'sp.providers.no_setup_group': 'Bez konfiguracji', diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 1523efed2..75b1868d9 100644 --- a/src/chrome/src/ui/locales/pt.js +++ b/src/chrome/src/ui/locales/pt.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Abrir a caixa de diálogo de impressão nativa da página atual', + 'sp.print.error': 'Não foi possível abrir a caixa de diálogo de impressão: {msg}', 'sp.streaming.fallback': 'A transmissão da resposta foi interrompida; tentando novamente esta interação Ask sem transmissão.', 'sp.providers.no_setup_group': "Nenhuma configuração necessária", 'sp.providers.no_setup': "Sem configuração", diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 37049cbfc..4643f3e1f 100644 --- a/src/chrome/src/ui/locales/ru.js +++ b/src/chrome/src/ui/locales/ru.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Открыть системное диалоговое окно печати текущей страницы', + 'sp.print.error': 'Не удалось открыть диалоговое окно печати: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Поток ответа был прерван; этот запрос Ask повторяется без потоковой передачи.', 'sp.providers.no_setup_group': 'Без настройки', diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index f01d11e7c..1cd152066 100644 --- a/src/chrome/src/ui/locales/th.js +++ b/src/chrome/src/ui/locales/th.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'เปิดกล่องโต้ตอบการพิมพ์ดั้งเดิมของหน้าปัจจุบัน', + 'sp.print.error': 'ไม่สามารถเปิดกล่องโต้ตอบการพิมพ์ได้: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'การสตรีมคำตอบถูกขัดจังหวะ กำลังลอง Ask รอบนี้อีกครั้งโดยไม่ใช้สตรีม', 'sp.providers.no_setup_group': 'ไม่ต้องตั้งค่า', diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index 0e2972da9..328e1990b 100644 --- a/src/chrome/src/ui/locales/tl.js +++ b/src/chrome/src/ui/locales/tl.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Buksan ang native na dialog ng pag-print ng kasalukuyang pahina', + 'sp.print.error': 'Hindi mabuksan ang dialog ng pag-print: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Naputol ang pag-stream ng tugon; sinusubukang muli ang Ask turn na ito nang walang streaming.', 'sp.providers.no_setup_group': 'Walang setup', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index a62c62efc..37516dfb6 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Geçerli sayfanın yerel yazdırma iletişim kutusunu aç', + 'sp.print.error': 'Yazdırma iletişim kutusu açılamadı: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Yanıt akışı kesildi; bu Ask turu akış olmadan yeniden deneniyor.', 'sp.providers.no_setup_group': 'Kurulum gerektirmez', diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index a396030d0..0f37dbc03 100644 --- a/src/chrome/src/ui/locales/uk.js +++ b/src/chrome/src/ui/locales/uk.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Відкрити системне діалогове вікно друку поточної сторінки', + 'sp.print.error': 'Не вдалося відкрити діалогове вікно друку: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Потік відповіді перервано; цей запит Ask повторюється без потокової передачі.', 'sp.providers.no_setup_group': 'Без налаштування', diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index 6d528f7ac..c66df90f8 100644 --- a/src/chrome/src/ui/locales/vi.js +++ b/src/chrome/src/ui/locales/vi.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Mở hộp thoại in gốc của trang hiện tại', + 'sp.print.error': 'Không thể mở hộp thoại in: {msg}', 'sp.streaming.fallback': 'Luồng phản hồi bị gián đoạn; đang thử lại lượt Ask này mà không phát trực tuyến.', 'sp.providers.no_setup_group': "Không cần thiết lập", 'sp.providers.no_setup': "Không cần thiết lập", diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index 27df79cc9..65e3422fd 100644 --- a/src/chrome/src/ui/locales/zh.js +++ b/src/chrome/src/ui/locales/zh.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': '打开当前页面的原生打印对话框', + 'sp.print.error': '无法打开打印对话框:{msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': '响应流已中断;正在以非流式方式重试本次 Ask。', 'sp.providers.no_setup_group': '无需配置', diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 9b3f89353..2dd349ae4 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -666,6 +666,7 @@ const SLASH_COMMANDS = [ { value: '/compact', usage: '/compact [prompt]', descriptionKey: 'sp.slash.compact', action: 'compact', acceptsPayload: true }, { value: '/verbose', usage: '/verbose', descriptionKey: 'sp.slash.verbose', action: 'toggle', outOfBand: true }, { value: '/reset', usage: '/reset', descriptionKey: 'sp.slash.reset', action: 'reset' }, + { value: '/print', usage: '/print', descriptionKey: 'sp.slash.print', action: 'print' }, { value: '/screenshot', usage: '/screenshot [--full-page]', @@ -7224,6 +7225,23 @@ function toggledVisionProviderConfig(providerId, config) { return { enabled, config: { ...config, supportsVision: enabled } }; } +async function executePrintSlashCommand(tabId, currentTabId, tabs, scripting, showToast, translate) { + try { + const tab = tabId == null ? null : await tabs.get(tabId); + if (currentTabId !== tabId || !tab?.active) return { skipped: true }; + await scripting.executeScript({ + target: { tabId }, + func: () => window.print(), + }); + return { ok: true }; + } catch (error) { + if (currentTabId === tabId) { + showToast(translate('sp.print.error', { msg: error?.message || 'unknown error' }), { duration: 5000 }); + } + return { error: error?.message || 'unknown error' }; + } +} + async function parseSlashCommands(text, tabId = currentTabId, options = {}) { if (/^\s*\/watch(?:\s|$)/i.test(text) && !/^\s*\/watch\s+--help\s*$/i.test(text)) { const watchArgs = parseWatchSlashCommand(text); @@ -7276,7 +7294,7 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { return ''; } - if ((command.value === '/screenshot' || command.value === '/record') + if ((command.value === '/screenshot' || command.value === '/record' || command.value === '/print') && isSelectionGroundedForTab(tabId)) { showComposerToast(t('sp.selection_scope.description'), { duration: 5000 }); return ''; @@ -7439,6 +7457,11 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { return ''; } + if (command.value === '/print') { + await executePrintSlashCommand(tabId, currentTabId, chrome.tabs, chrome.scripting, showComposerToast, t); + return ''; + } + if (command.value === '/screenshot' && action === 'viewport') { try { const tab = tabId == null ? null : await chrome.tabs.get(tabId); @@ -7899,7 +7922,7 @@ async function sendMessage(extraChatParams = {}) { } let runCaptureDirective = null; if (!retryOptions) { - if (sourceGrounding && /^\s*\/(?:screenshot|record)(?:\s|$)/i.test(text)) { + if (sourceGrounding && /^\s*\/(?:screenshot|record|print)(?:\s|$)/i.test(text)) { showComposerToast(t('sp.selection_scope.description'), { duration: 5000 }); return false; } diff --git a/src/firefox/src/ui/locales/ar.js b/src/firefox/src/ui/locales/ar.js index bce35c0e6..80da198cf 100644 --- a/src/firefox/src/ui/locales/ar.js +++ b/src/firefox/src/ui/locales/ar.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'فتح مربع حوار الطباعة الأصلي للصفحة الحالية', + 'sp.print.error': 'تعذر فتح مربع حوار الطباعة: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'انقطع بث الاستجابة؛ تتم إعادة محاولة دور Ask هذا بدون بث.', 'sp.providers.no_setup_group': 'لا يتطلب إعدادًا', diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js index de2e6dab2..a4a6f7cbf 100644 --- a/src/firefox/src/ui/locales/bn.js +++ b/src/firefox/src/ui/locales/bn.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'বর্তমান পৃষ্ঠার নেটিভ প্রিন্ট ডায়ালগ খুলুন', + 'sp.print.error': 'প্রিন্ট ডায়ালগ খোলা যায়নি: {msg}', 'sp.streaming.fallback': 'প্রতিক্রিয়া স্ট্রিম বাধাগ্রস্ত হয়েছে; স্ট্রিমিং ছাড়া এই Ask পালাটি আবার চেষ্টা করা হচ্ছে।', 'sp.providers.no_setup_group': "কোন সেটআপ প্রয়োজন", 'sp.providers.no_setup': "কোনো সেটআপ নেই", diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js index 7bee5c0c0..e9a14d40d 100644 --- a/src/firefox/src/ui/locales/de.js +++ b/src/firefox/src/ui/locales/de.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Nativen Druckdialog der aktuellen Seite öffnen', + 'sp.print.error': 'Druckdialog konnte nicht geöffnet werden: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Der Antwortstream wurde unterbrochen; dieser Ask-Durchgang wird ohne Streaming erneut versucht.', 'sp.providers.no_setup_group': 'Keine Einrichtung erforderlich', diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index bbbc56682..9043b6d2f 100644 --- a/src/firefox/src/ui/locales/en.js +++ b/src/firefox/src/ui/locales/en.js @@ -2,6 +2,8 @@ import apocalypseModeCopy from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Open the current page’s native print dialog', + 'sp.print.error': 'Could not open the print dialog: {msg}', 'sp.streaming.fallback': 'Response streaming was interrupted; retrying this Ask turn without streaming.', 'sp.providers.no_setup_group': 'No setup required', 'sp.providers.no_setup': 'No setup', diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js index bf1e9fdee..142b15723 100644 --- a/src/firefox/src/ui/locales/es.js +++ b/src/firefox/src/ui/locales/es.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Abrir el diálogo de impresión nativo de la página actual', + 'sp.print.error': 'No se pudo abrir el diálogo de impresión: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Se interrumpió la transmisión de la respuesta; reintentando este turno de Ask sin transmisión.', 'sp.providers.no_setup_group': 'Sin configuración', diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js index d726a4c8c..2bcbd817c 100644 --- a/src/firefox/src/ui/locales/fa.js +++ b/src/firefox/src/ui/locales/fa.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'باز کردن گفت‌وگوی چاپ بومی صفحهٔ فعلی', + 'sp.print.error': 'گفت‌وگوی چاپ باز نشد: {msg}', 'sp.streaming.fallback': 'جریان پاسخ قطع شد؛ این نوبت Ask بدون پخش جریانی دوباره امتحان می‌شود.', 'sp.providers.no_setup_group': "بدون نیاز به راه اندازی", 'sp.providers.no_setup': "بدون راه اندازی", diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js index f626260cb..395764fb4 100644 --- a/src/firefox/src/ui/locales/fr.js +++ b/src/firefox/src/ui/locales/fr.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Ouvrir la boîte de dialogue d’impression native de la page actuelle', + 'sp.print.error': 'Impossible d’ouvrir la boîte de dialogue d’impression : {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Le flux de réponse a été interrompu ; nouvelle tentative de ce tour Ask sans streaming.', 'sp.providers.no_setup_group': 'Sans configuration', diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js index 660513fa7..ebaa29d80 100644 --- a/src/firefox/src/ui/locales/he.js +++ b/src/firefox/src/ui/locales/he.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'פתיחת תיבת הדו־שיח המקורית להדפסת הדף הנוכחי', + 'sp.print.error': 'לא ניתן לפתוח את תיבת הדו־שיח להדפסה: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'הזרמת התשובה נקטעה; מתבצע ניסיון חוזר לתור Ask הזה ללא הזרמה.', 'sp.providers.no_setup_group': 'ללא הגדרה', diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js index a91b3b2e7..048a7336f 100644 --- a/src/firefox/src/ui/locales/hi.js +++ b/src/firefox/src/ui/locales/hi.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'वर्तमान पृष्ठ का मूल प्रिंट संवाद खोलें', + 'sp.print.error': 'प्रिंट संवाद नहीं खुल सका: {msg}', 'sp.streaming.fallback': 'प्रतिक्रिया स्ट्रीम बाधित हुई; इस Ask टर्न को बिना स्ट्रीमिंग के फिर से आज़माया जा रहा है।', 'sp.providers.no_setup_group': "किसी सेटअप की आवश्यकता नहीं है", 'sp.providers.no_setup': "कोई सेटअप नहीं", diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js index 6242c798b..89a00e197 100644 --- a/src/firefox/src/ui/locales/id.js +++ b/src/firefox/src/ui/locales/id.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Buka dialog cetak bawaan halaman saat ini', + 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Streaming respons terputus; mencoba kembali giliran Ask ini tanpa streaming.', 'sp.providers.no_setup_group': 'Tanpa penyiapan', diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js index 64e1dcba1..4ba0c92d6 100644 --- a/src/firefox/src/ui/locales/ja.js +++ b/src/firefox/src/ui/locales/ja.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': '現在のページのネイティブ印刷ダイアログを開く', + 'sp.print.error': '印刷ダイアログを開けませんでした: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': '応答ストリームが中断されました。この Ask ターンをストリーミングなしで再試行します。', 'sp.providers.no_setup_group': '設定不要', diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js index fc374be12..da4c5d988 100644 --- a/src/firefox/src/ui/locales/ko.js +++ b/src/firefox/src/ui/locales/ko.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': '현재 페이지의 기본 인쇄 대화상자 열기', + 'sp.print.error': '인쇄 대화상자를 열 수 없습니다: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': '응답 스트리밍이 중단되었습니다. 이 Ask 요청을 스트리밍 없이 다시 시도합니다.', 'sp.providers.no_setup_group': '설정 필요 없음', diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js index 0fcc7e8ff..286af4d48 100644 --- a/src/firefox/src/ui/locales/ms.js +++ b/src/firefox/src/ui/locales/ms.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Buka dialog cetak asli halaman semasa', + 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Penstriman respons terganggu; mencuba semula giliran Ask ini tanpa penstriman.', 'sp.providers.no_setup_group': 'Tanpa persediaan', diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js index dade456a4..238e73239 100644 --- a/src/firefox/src/ui/locales/nl.js +++ b/src/firefox/src/ui/locales/nl.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Het systeemeigen afdrukvenster van de huidige pagina openen', + 'sp.print.error': 'Het afdrukvenster kon niet worden geopend: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'De antwoordstream is onderbroken; deze Ask-beurt wordt opnieuw geprobeerd zonder streaming.', 'sp.providers.no_setup_group': 'Geen configuratie nodig', diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js index ee82c0ca6..599f9f900 100644 --- a/src/firefox/src/ui/locales/pl.js +++ b/src/firefox/src/ui/locales/pl.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Otwórz natywne okno drukowania bieżącej strony', + 'sp.print.error': 'Nie udało się otworzyć okna drukowania: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Strumieniowanie odpowiedzi zostało przerwane; ponawiam tę turę Ask bez strumieniowania.', 'sp.providers.no_setup_group': 'Bez konfiguracji', diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js index 375ae7d65..f72cf61a2 100644 --- a/src/firefox/src/ui/locales/pt.js +++ b/src/firefox/src/ui/locales/pt.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Abrir a caixa de diálogo de impressão nativa da página atual', + 'sp.print.error': 'Não foi possível abrir a caixa de diálogo de impressão: {msg}', 'sp.streaming.fallback': 'A transmissão da resposta foi interrompida; tentando novamente esta interação Ask sem transmissão.', 'sp.providers.no_setup_group': "Nenhuma configuração necessária", 'sp.providers.no_setup': "Sem configuração", diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js index 3ae873e43..4f10132f7 100644 --- a/src/firefox/src/ui/locales/ru.js +++ b/src/firefox/src/ui/locales/ru.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Открыть системное диалоговое окно печати текущей страницы', + 'sp.print.error': 'Не удалось открыть диалоговое окно печати: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Поток ответа был прерван; этот запрос Ask повторяется без потоковой передачи.', 'sp.providers.no_setup_group': 'Без настройки', diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js index 2a1157d86..aa9c46c66 100644 --- a/src/firefox/src/ui/locales/th.js +++ b/src/firefox/src/ui/locales/th.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'เปิดกล่องโต้ตอบการพิมพ์ดั้งเดิมของหน้าปัจจุบัน', + 'sp.print.error': 'ไม่สามารถเปิดกล่องโต้ตอบการพิมพ์ได้: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'การสตรีมคำตอบถูกขัดจังหวะ กำลังลอง Ask รอบนี้อีกครั้งโดยไม่ใช้สตรีม', 'sp.providers.no_setup_group': 'ไม่ต้องตั้งค่า', diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js index 1c2d41182..3cfc0657d 100644 --- a/src/firefox/src/ui/locales/tl.js +++ b/src/firefox/src/ui/locales/tl.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Buksan ang native na dialog ng pag-print ng kasalukuyang pahina', + 'sp.print.error': 'Hindi mabuksan ang dialog ng pag-print: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Naputol ang pag-stream ng tugon; sinusubukang muli ang Ask turn na ito nang walang streaming.', 'sp.providers.no_setup_group': 'Walang setup', diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index 01be5822f..7fc178bcc 100644 --- a/src/firefox/src/ui/locales/tr.js +++ b/src/firefox/src/ui/locales/tr.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Geçerli sayfanın yerel yazdırma iletişim kutusunu aç', + 'sp.print.error': 'Yazdırma iletişim kutusu açılamadı: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Yanıt akışı kesildi; bu Ask turu akış olmadan yeniden deneniyor.', 'sp.providers.no_setup_group': 'Kurulum gerektirmez', diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js index 0c874ca00..43b80975e 100644 --- a/src/firefox/src/ui/locales/uk.js +++ b/src/firefox/src/ui/locales/uk.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Відкрити системне діалогове вікно друку поточної сторінки', + 'sp.print.error': 'Не вдалося відкрити діалогове вікно друку: {msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Потік відповіді перервано; цей запит Ask повторюється без потокової передачі.', 'sp.providers.no_setup_group': 'Без налаштування', diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js index 3cee7c5ff..dca99b7d0 100644 --- a/src/firefox/src/ui/locales/vi.js +++ b/src/firefox/src/ui/locales/vi.js @@ -2,6 +2,8 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': 'Mở hộp thoại in gốc của trang hiện tại', + 'sp.print.error': 'Không thể mở hộp thoại in: {msg}', 'sp.streaming.fallback': 'Luồng phản hồi bị gián đoạn; đang thử lại lượt Ask này mà không phát trực tuyến.', 'sp.providers.no_setup_group': "Không cần thiết lập", 'sp.providers.no_setup': "Không cần thiết lập", diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js index 1996deb76..7ae459f06 100644 --- a/src/firefox/src/ui/locales/zh.js +++ b/src/firefox/src/ui/locales/zh.js @@ -4,6 +4,8 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + 'sp.slash.print': '打开当前页面的原生打印对话框', + 'sp.print.error': '无法打开打印对话框:{msg}', ...chromeWebStoreLocale, 'sp.streaming.fallback': '响应流已中断;正在以非流式方式重试本次 Ask。', 'sp.providers.no_setup_group': '无需配置', diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 6c5ad8475..99db46a49 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -544,6 +544,7 @@ const SLASH_COMMANDS = [ { value: '/compact', usage: '/compact [prompt]', descriptionKey: 'sp.slash.compact', action: 'compact', acceptsPayload: true }, { value: '/verbose', usage: '/verbose', descriptionKey: 'sp.slash.verbose', action: 'toggle', outOfBand: true }, { value: '/reset', usage: '/reset', descriptionKey: 'sp.slash.reset', action: 'reset' }, + { value: '/print', usage: '/print', descriptionKey: 'sp.slash.print', action: 'print' }, { value: '/screenshot', usage: '/screenshot', @@ -7065,6 +7066,20 @@ function toggledVisionProviderConfig(providerId, config) { return { enabled, config: { ...config, supportsVision: enabled } }; } +async function executePrintSlashCommand(tabId, currentTabId, tabs, showToast, translate) { + try { + const tab = tabId == null ? null : await tabs.get(tabId); + if (currentTabId !== tabId || !tab?.active) return { skipped: true }; + await tabs.executeScript(tabId, { code: 'window.print();' }); + return { ok: true }; + } catch (error) { + if (currentTabId === tabId) { + showToast(translate('sp.print.error', { msg: error?.message || 'unknown error' }), { duration: 5000 }); + } + return { error: error?.message || 'unknown error' }; + } +} + async function parseSlashCommands(text, tabId = currentTabId, options = {}) { if (/^\s*\/watch(?:\s|$)/i.test(text) && !/^\s*\/watch\s+--help\s*$/i.test(text)) { const watchArgs = parseWatchSlashCommand(text); @@ -7117,7 +7132,7 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { return ''; } - if ((command.value === '/screenshot' || command.value === '/record') + if ((command.value === '/screenshot' || command.value === '/record' || command.value === '/print') && isSelectionGroundedForTab(tabId)) { showComposerToast(t('sp.selection_scope.description'), { duration: 5000 }); return ''; @@ -7280,6 +7295,11 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { return ''; } + if (command.value === '/print') { + await executePrintSlashCommand(tabId, currentTabId, browser.tabs, showComposerToast, t); + return ''; + } + if (command.value === '/screenshot' && action === 'viewport') { try { const tab = tabId == null ? null : await browser.tabs.get(tabId); @@ -7631,7 +7651,7 @@ async function sendMessage(extraChatParams = {}) { } let runCaptureDirective = null; if (!retryOptions) { - if (sourceGrounding && /^\s*\/(?:screenshot|record)(?:\s|$)/i.test(text)) { + if (sourceGrounding && /^\s*\/(?:screenshot|record|print)(?:\s|$)/i.test(text)) { showComposerToast(t('sp.selection_scope.description'), { duration: 5000 }); return false; } diff --git a/test/run.js b/test/run.js index c9cd8978e..24379c262 100644 --- a/test/run.js +++ b/test/run.js @@ -23494,6 +23494,146 @@ test('sidepanel exposes schedule slash commands in both builds', () => { } }); +test('/print opens the current page native print dialog in both builds', () => { + const docs = fs.readFileSync(path.join(ROOT, 'docs/slash-commands.md'), 'utf8'); + assert.match(docs, /\| `\/print` \| Open the current page's native print dialog \|/); + + for (const [label, panelRel, localeRel] of [ + ['chrome', 'src/chrome/src/ui/sidepanel.js', 'src/chrome/src/ui/locales/en.js'], + ['firefox', 'src/firefox/src/ui/sidepanel.js', 'src/firefox/src/ui/locales/en.js'], + ]) { + const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); + const locale = fs.readFileSync(path.join(ROOT, localeRel), 'utf8'); + const slash = loadSlashCommandRuntime(panelRel); + const invocation = slash.parseSlashInvocation('/print'); + + assert.equal(invocation.command.value, '/print', `${label}: /print should be discoverable`); + assert.equal(invocation.command.usage, '/print', `${label}: /print should not advertise format or selection arguments`); + assert.equal(invocation.action, 'print', `${label}: /print action missing`); + assert.equal(slash.slashInvocationIsOutOfBand(invocation), false, `${label}: /print should wait until an active run finishes`); + assert.equal(slash.parseSlashInvocation('/print selected')?.error, 'invalid-usage', `${label}: /print should reject unsupported selection or format arguments`); + assert.match(locale, /'sp\.slash\.print': 'Open the current page’s native print dialog'/, `${label}: /print description missing`); + assert.match(locale, /'sp\.print\.error': 'Could not open the print dialog: \{msg\}'/, `${label}: /print failure message missing`); + + const routeStart = panel.indexOf("if (command.value === '/print') {"); + assert.notEqual(routeStart, -1, `${label}: /print parser route missing`); + const routeEnd = panel.indexOf("if (command.value === '/screenshot'", routeStart); + assert.notEqual(routeEnd, -1, `${label}: /print parser route boundary missing`); + const route = panel.slice(routeStart, routeEnd); + assert.match(route, /executePrintSlashCommand\(tabId, currentTabId,/, `${label}: /print should delegate to executePrintSlashCommand`); + const helperStart = panel.indexOf('async function executePrintSlashCommand('); + assert.notEqual(helperStart, -1, `${label}: executePrintSlashCommand helper missing`); + const helperEnd = panel.indexOf('\n}\n\nasync function parseSlashCommands', helperStart); + assert.notEqual(helperEnd, -1, `${label}: executePrintSlashCommand boundary missing`); + const helper = panel.slice(helperStart, helperEnd); + assert.match(helper, /tabs\.get\(tabId\)/, `${label}: /print should validate the initiating tab`); + assert.match(helper, /currentTabId !== tabId \|\| !tab\?\.active/, `${label}: /print should not target a stale or background tab`); + assert.match(helper, /sp\.print\.error/, `${label}: /print failures should be visible`); + if (label === 'chrome') { + assert.match(helper, /scripting\.executeScript\(\{[\s\S]*?target: \{ tabId \},[\s\S]*?func: \(\) => window\.print\(\)/, 'chrome: /print should invoke the page print dialog through MV3 scripting'); + } else { + assert.match(helper, /tabs\.executeScript\(tabId, \{ code: 'window\.print\(\);' \}\)/, 'firefox: /print should invoke the page print dialog through MV2 scripting'); + } + } +}); + +test('/print routes are exercised with mocked tab and injection APIs in both builds', async () => { + for (const [label, panelRel] of [ + ['chrome', 'src/chrome/src/ui/sidepanel.js'], + ['firefox', 'src/firefox/src/ui/sidepanel.js'], + ]) { + const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); + const fnStart = panel.indexOf('async function executePrintSlashCommand('); + assert.notEqual(fnStart, -1, `${label}: executePrintSlashCommand helper missing`); + const fnEnd = panel.indexOf('\n}\n\nasync function parseSlashCommands', fnStart); + assert.notEqual(fnEnd, -1, `${label}: executePrintSlashCommand boundary missing`); + const executePrintSlashCommand = vm.runInNewContext( + `(() => { ${panel.slice(fnStart, fnEnd + 2)}; return executePrintSlashCommand; })()`, + {} + ); + + // Success path: active tab, injection succeeds. + let getCalled = false; + let executeCalled = false; + const successTabs = { + get: async (id) => { + getCalled = true; + assert.equal(id, 7, `${label}: should query the initiating tab`); + return { id: 7, active: true }; + }, + }; + const successScripting = { + executeScript: async ({ target, func }) => { + executeCalled = true; + assert.equal(target.tabId, 7, `${label}: should target the initiating tab`); + assert.equal(typeof func, 'function', `${label}: should pass a function for injection`); + }, + }; + const toasts = []; + const showToast = (msg) => { toasts.push(msg); }; + const t = (key, params) => `${key}:${JSON.stringify(params || {})}`; + + const successResult = label === 'chrome' + ? await executePrintSlashCommand(7, 7, successTabs, successScripting, showToast, t) + : await executePrintSlashCommand(7, 7, { ...successTabs, executeScript: async (tabId, details) => { + executeCalled = true; + assert.equal(tabId, 7, `${label}: should target the initiating tab`); + assert.equal(details.code, "window.print();", `${label}: should pass the print script`); + } }, showToast, t); + assert.equal(successResult.ok, true, `${label}: success path should return ok`); + assert.equal(getCalled, true, `${label}: success path should call tabs.get`); + assert.equal(executeCalled, true, `${label}: success path should call executeScript`); + + // Stale-tab guard: currentTabId differs from the command tab. + getCalled = false; + executeCalled = false; + const staleResult = label === 'chrome' + ? await executePrintSlashCommand(7, 99, successTabs, successScripting, showToast, t) + : await executePrintSlashCommand(7, 99, { ...successTabs, executeScript: async () => { executeCalled = true; } }, showToast, t); + assert.equal(staleResult.skipped, true, `${label}: stale tab should skip without injecting`); + assert.equal(getCalled, true, `${label}: stale tab should still validate the tab`); + assert.equal(executeCalled, false, `${label}: stale tab should not inject`); + + // Background-tab guard: tab exists but is not active. + getCalled = false; + executeCalled = false; + const inactiveTabs = { + get: async (id) => { + getCalled = true; + assert.equal(id, 7, `${label}: should query the initiating tab`); + return { id: 7, active: false }; + }, + }; + const inactiveResult = label === 'chrome' + ? await executePrintSlashCommand(7, 7, inactiveTabs, successScripting, showToast, t) + : await executePrintSlashCommand(7, 7, { ...inactiveTabs, executeScript: async () => { executeCalled = true; } }, showToast, t); + assert.equal(inactiveResult.skipped, true, `${label}: inactive tab should skip without injecting`); + assert.equal(getCalled, true, `${label}: inactive tab should still validate the tab`); + assert.equal(executeCalled, false, `${label}: inactive tab should not inject`); + + // Rejected-injection path: injection throws. + getCalled = false; + executeCalled = false; + const rejectTabs = { + get: async (id) => { + getCalled = true; + assert.equal(id, 7, `${label}: should query the initiating tab`); + return { id: 7, active: true }; + }, + }; + const rejectScripting = { + executeScript: async () => { executeCalled = true; throw new Error('injection blocked'); }, + }; + const rejectResult = label === 'chrome' + ? await executePrintSlashCommand(7, 7, rejectTabs, rejectScripting, showToast, t) + : await executePrintSlashCommand(7, 7, { ...rejectTabs, executeScript: async () => { throw new Error('injection blocked'); } }, showToast, t); + assert.equal(rejectResult.error, 'injection blocked', `${label}: should surface injection failure`); + assert.equal(getCalled, true, `${label}: rejection path should still validate the tab`); + assert.equal(executeCalled, label === 'chrome' ? true : false, `${label}: rejection path should attempt injection`); + assert.ok(toasts.some(t => t.includes('sp.print.error')), `${label}: should show localized error toast on rejection`); + } +}); + test('/watch slash parser keeps Chrome and Firefox validation aligned', async () => { const chromeWatch = await import(pathToFileURL(path.join(ROOT, 'src/chrome/src/ui/watch-command.js')).href); const firefoxWatch = await import(pathToFileURL(path.join(ROOT, 'src/firefox/src/ui/watch-command.js')).href); @@ -24421,8 +24561,8 @@ test('selected-text scope is a durable visible sidepanel state with a New conver assert.match(panel, /const modeForSend = retryOptions\?\.mode \|\| modeOverride \|\| modeForMessageText\(text\);\s*if \(rejectSelectionScopedMode\(modeForSend, tabId, sourceGrounding\)\) return false;/, `${label}: chat start should enforce the selected-scope mode boundary centrally`); assert.match(panel, /async function continueAgent\(options = \{\}\) \{[\s\S]*?const modeForSend =[\s\S]*?if \(rejectSelectionScopedMode\(modeForSend, tabId\)\) return false;[\s\S]*?sendRunWithReconnect\('continue_start'/, `${label}: Continue should enforce the selected-scope mode boundary centrally`); assert.match(panel, /async function startSavedWorkflowRun\(workflow, parameters, tabId = currentTabId\) \{[\s\S]*?if \(!\(await ensureActMode\(\)\)\) return false;[\s\S]*?return sendMessage\(/, `${label}: saved workflows should stop when selected-text scope rejects Act mode`); - assert.match(panel, /if \(\(command\.value === '\/screenshot' \|\| command\.value === '\/record'\)[\s\S]*?isSelectionGroundedForTab\(tabId\)\) \{[\s\S]*?sp\.selection_scope\.description[\s\S]*?return '';[\s\S]*?command\.value === '\/screenshot' && action === 'viewport'/, `${label}: page-capture slash commands should stop before dispatch in selected-text conversations`); - assert.match(panel, /if \(!retryOptions\) \{\s*if \(sourceGrounding && \/\^\\s\*\\\/\(\?:screenshot\|record\)[\s\S]*?sp\.selection_scope\.description[\s\S]*?return false;[\s\S]*?parseTrailingRunCaptureDirective\(text\);/, `${label}: newly selected-text sends should reject standalone page-capture commands before slash dispatch`); + assert.match(panel, /if \(\(command\.value === '\/screenshot' \|\| command\.value === '\/record' \|\| command\.value === '\/print'\)[\s\S]*?isSelectionGroundedForTab\(tabId\)\) \{[\s\S]*?sp\.selection_scope\.description[\s\S]*?return '';[\s\S]*?command\.value === '\/screenshot' && action === 'viewport'/, `${label}: page and screen slash commands should stop before dispatch in selected-text conversations`); + assert.match(panel, /if \(!retryOptions\) \{\s*if \(sourceGrounding && \/\^\\s\*\\\/\(\?:screenshot\|record\|print\)[\s\S]*?sp\.selection_scope\.description[\s\S]*?return false;[\s\S]*?parseTrailingRunCaptureDirective\(text\);/, `${label}: newly selected-text sends should reject standalone page and screen commands before slash dispatch`); assert.match(panel, /runCaptureDirective = parseTrailingRunCaptureDirective\(text\);[\s\S]*?if \(runCaptureDirective[\s\S]*?sourceGrounding \|\| isSelectionGroundedForTab\(tabId\)[\s\S]*?sp\.selection_scope\.description[\s\S]*?return false;[\s\S]*?text = runCaptureDirective\.prompt;/, `${label}: trailing page-capture directives should stop before prompt dispatch in selected-text conversations`); assert.match(panel, /function reconcileFailedSelectionGroundedStart\(tabId, \{[\s\S]*?sourceGrounding,[\s\S]*?selectionGroundedBeforeSend,[\s\S]*?accepted,[\s\S]*?if \(accepted \|\| \(!sourceGrounding && !selectionGroundedBeforeSend\)\) return;[\s\S]*?restoreActiveRunState\(tabId\);/, `${label}: failed explicit and inherited selected-text starts should reconcile while preserving local scope until the authoritative probe succeeds`); assert.doesNotMatch(panel.slice(